Repository: beetbox/beets Branch: master Commit: 1943b145657a Files: 518 Total size: 4.9 MB Directory structure: gitextract_bueceyr9/ ├── .git-blame-ignore-revs ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ ├── config.yml │ │ └── feature-request.md │ ├── copilot-instructions.md │ ├── problem-matchers/ │ │ ├── sphinx-build.json │ │ └── sphinx-lint.json │ ├── pull_request_template.md │ ├── stale.yml │ └── workflows/ │ ├── changelog_reminder.yaml │ ├── ci.yaml │ ├── integration_test.yaml │ ├── lint.yaml │ └── make_release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── README_kr.rst ├── SECURITY.md ├── beets/ │ ├── __init__.py │ ├── __main__.py │ ├── autotag/ │ │ ├── __init__.py │ │ ├── distance.py │ │ ├── hooks.py │ │ └── match.py │ ├── config_default.yaml │ ├── dbcore/ │ │ ├── __init__.py │ │ ├── db.py │ │ ├── query.py │ │ ├── queryparse.py │ │ └── types.py │ ├── importer/ │ │ ├── __init__.py │ │ ├── session.py │ │ ├── stages.py │ │ ├── state.py │ │ └── tasks.py │ ├── library/ │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── library.py │ │ ├── migrations.py │ │ ├── models.py │ │ └── queries.py │ ├── logging.py │ ├── mediafile.py │ ├── metadata_plugins.py │ ├── plugins.py │ ├── py.typed │ ├── test/ │ │ ├── __init__.py │ │ ├── _common.py │ │ └── helper.py │ ├── ui/ │ │ ├── __init__.py │ │ └── commands/ │ │ ├── __init__.py │ │ ├── completion.py │ │ ├── completion_base.sh │ │ ├── config.py │ │ ├── fields.py │ │ ├── help.py │ │ ├── import_/ │ │ │ ├── __init__.py │ │ │ ├── display.py │ │ │ └── session.py │ │ ├── list.py │ │ ├── modify.py │ │ ├── move.py │ │ ├── remove.py │ │ ├── stats.py │ │ ├── update.py │ │ ├── utils.py │ │ ├── version.py │ │ └── write.py │ └── util/ │ ├── __init__.py │ ├── artresizer.py │ ├── bluelet.py │ ├── color.py │ ├── config.py │ ├── deprecation.py │ ├── diff.py │ ├── functemplate.py │ ├── hidden.py │ ├── id_extractors.py │ ├── layout.py │ ├── lyrics.py │ ├── m3u.py │ ├── pipeline.py │ └── units.py ├── beetsplug/ │ ├── _typing.py │ ├── _utils/ │ │ ├── __init__.py │ │ ├── art.py │ │ ├── musicbrainz.py │ │ ├── requests.py │ │ └── vfs.py │ ├── absubmit.py │ ├── acousticbrainz.py │ ├── advancedrewrite.py │ ├── albumtypes.py │ ├── aura.py │ ├── autobpm.py │ ├── badfiles.py │ ├── bareasc.py │ ├── beatport.py │ ├── bench.py │ ├── bpd/ │ │ ├── __init__.py │ │ └── gstplayer.py │ ├── bpm.py │ ├── bpsync.py │ ├── bucket.py │ ├── chroma.py │ ├── convert.py │ ├── deezer.py │ ├── discogs/ │ │ ├── __init__.py │ │ ├── states.py │ │ └── types.py │ ├── duplicates.py │ ├── edit.py │ ├── embedart.py │ ├── embyupdate.py │ ├── export.py │ ├── fetchart.py │ ├── filefilter.py │ ├── fish.py │ ├── freedesktop.py │ ├── fromfilename.py │ ├── ftintitle.py │ ├── fuzzy.py │ ├── hook.py │ ├── ihate.py │ ├── importadded.py │ ├── importfeeds.py │ ├── importsource.py │ ├── info.py │ ├── inline.py │ ├── ipfs.py │ ├── keyfinder.py │ ├── kodiupdate.py │ ├── lastgenre/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── genres-tree.yaml │ │ └── genres.txt │ ├── lastimport.py │ ├── limit.py │ ├── listenbrainz.py │ ├── loadext.py │ ├── lyrics.py │ ├── mbcollection.py │ ├── mbpseudo.py │ ├── mbsubmit.py │ ├── mbsync.py │ ├── metasync/ │ │ ├── __init__.py │ │ ├── amarok.py │ │ └── itunes.py │ ├── missing.py │ ├── mpdstats.py │ ├── mpdupdate.py │ ├── musicbrainz.py │ ├── parentwork.py │ ├── permissions.py │ ├── play.py │ ├── playlist.py │ ├── plexupdate.py │ ├── random.py │ ├── replace.py │ ├── replaygain.py │ ├── rewrite.py │ ├── scrub.py │ ├── smartplaylist.py │ ├── sonosupdate.py │ ├── spotify.py │ ├── subsonicplaylist.py │ ├── subsonicupdate.py │ ├── substitute.py │ ├── the.py │ ├── thumbnails.py │ ├── titlecase.py │ ├── types.py │ ├── unimported.py │ ├── web/ │ │ ├── __init__.py │ │ ├── static/ │ │ │ ├── backbone.js │ │ │ ├── beets.css │ │ │ ├── beets.js │ │ │ ├── jquery.js │ │ │ └── underscore.js │ │ └── templates/ │ │ └── index.html │ └── zero.py ├── codecov.yml ├── docs/ │ ├── .gitignore │ ├── Makefile │ ├── _static/ │ │ └── beets.css │ ├── _templates/ │ │ └── autosummary/ │ │ ├── base.rst │ │ ├── class.rst │ │ ├── module.rst │ │ └── namedtuple.rst │ ├── api/ │ │ ├── database.rst │ │ ├── index.rst │ │ ├── plugin_utilities.rst │ │ └── plugins.rst │ ├── changelog.rst │ ├── code_of_conduct.rst │ ├── conf.py │ ├── contributing.rst │ ├── dev/ │ │ ├── cli.rst │ │ ├── importer.rst │ │ ├── index.rst │ │ ├── library.rst │ │ ├── paths.rst │ │ └── plugins/ │ │ ├── autotagger.rst │ │ ├── commands.rst │ │ ├── events.rst │ │ ├── index.rst │ │ └── other/ │ │ ├── config.rst │ │ ├── fields.rst │ │ ├── import.rst │ │ ├── index.rst │ │ ├── logging.rst │ │ ├── mediafile.rst │ │ ├── prompts.rst │ │ └── templates.rst │ ├── extensions/ │ │ └── conf.py │ ├── faq.rst │ ├── guides/ │ │ ├── advanced.rst │ │ ├── index.rst │ │ ├── installation.rst │ │ ├── main.rst │ │ └── tagger.rst │ ├── index.rst │ ├── modd.conf │ ├── plugins/ │ │ ├── absubmit.rst │ │ ├── acousticbrainz.rst │ │ ├── advancedrewrite.rst │ │ ├── albumtypes.rst │ │ ├── aura.rst │ │ ├── autobpm.rst │ │ ├── badfiles.rst │ │ ├── bareasc.rst │ │ ├── beatport.rst │ │ ├── bpd.rst │ │ ├── bpm.rst │ │ ├── bpsync.rst │ │ ├── bucket.rst │ │ ├── chroma.rst │ │ ├── convert.rst │ │ ├── deezer.rst │ │ ├── discogs.rst │ │ ├── duplicates.rst │ │ ├── edit.rst │ │ ├── embedart.rst │ │ ├── embyupdate.rst │ │ ├── export.rst │ │ ├── fetchart.rst │ │ ├── filefilter.rst │ │ ├── fish.rst │ │ ├── freedesktop.rst │ │ ├── fromfilename.rst │ │ ├── ftintitle.rst │ │ ├── fuzzy.rst │ │ ├── hook.rst │ │ ├── ihate.rst │ │ ├── importadded.rst │ │ ├── importfeeds.rst │ │ ├── importsource.rst │ │ ├── index.rst │ │ ├── info.rst │ │ ├── inline.rst │ │ ├── ipfs.rst │ │ ├── keyfinder.rst │ │ ├── kodiupdate.rst │ │ ├── lastgenre.rst │ │ ├── lastimport.rst │ │ ├── limit.rst │ │ ├── listenbrainz.rst │ │ ├── loadext.rst │ │ ├── lyrics.rst │ │ ├── mbcollection.rst │ │ ├── mbpseudo.rst │ │ ├── mbsubmit.rst │ │ ├── mbsync.rst │ │ ├── metasync.rst │ │ ├── missing.rst │ │ ├── mpdstats.rst │ │ ├── mpdupdate.rst │ │ ├── musicbrainz.rst │ │ ├── parentwork.rst │ │ ├── permissions.rst │ │ ├── play.rst │ │ ├── playlist.rst │ │ ├── plexupdate.rst │ │ ├── random.rst │ │ ├── replace.rst │ │ ├── replaygain.rst │ │ ├── rewrite.rst │ │ ├── scrub.rst │ │ ├── shared_metadata_source_config.rst │ │ ├── smartplaylist.rst │ │ ├── sonosupdate.rst │ │ ├── spotify.rst │ │ ├── subsonicplaylist.rst │ │ ├── subsonicupdate.rst │ │ ├── substitute.rst │ │ ├── the.rst │ │ ├── thumbnails.rst │ │ ├── titlecase.rst │ │ ├── types.rst │ │ ├── unimported.rst │ │ ├── web.rst │ │ └── zero.rst │ ├── reference/ │ │ ├── cli.rst │ │ ├── config.rst │ │ ├── index.rst │ │ ├── pathformat.rst │ │ └── query.rst │ └── team.rst ├── extra/ │ ├── _beet │ ├── ascii_logo.txt │ ├── beets.reg │ └── release.py ├── pyproject.toml ├── setup.cfg └── test/ ├── __init__.py ├── autotag/ │ ├── __init__.py │ ├── test_autotag.py │ ├── test_distance.py │ ├── test_hooks.py │ └── test_match.py ├── conftest.py ├── library/ │ ├── __init__.py │ └── test_migrations.py ├── plugins/ │ ├── __init__.py │ ├── conftest.py │ ├── lyrics_pages.py │ ├── test_acousticbrainz.py │ ├── test_advancedrewrite.py │ ├── test_albumtypes.py │ ├── test_art.py │ ├── test_aura.py │ ├── test_autobpm.py │ ├── test_bareasc.py │ ├── test_beatport.py │ ├── test_bpd.py │ ├── test_bucket.py │ ├── test_convert.py │ ├── test_discogs.py │ ├── test_edit.py │ ├── test_embedart.py │ ├── test_embyupdate.py │ ├── test_export.py │ ├── test_fetchart.py │ ├── test_filefilter.py │ ├── test_fromfilename.py │ ├── test_ftintitle.py │ ├── test_fuzzy.py │ ├── test_hook.py │ ├── test_ihate.py │ ├── test_importadded.py │ ├── test_importfeeds.py │ ├── test_importsource.py │ ├── test_info.py │ ├── test_inline.py │ ├── test_ipfs.py │ ├── test_keyfinder.py │ ├── test_lastgenre.py │ ├── test_limit.py │ ├── test_listenbrainz.py │ ├── test_lyrics.py │ ├── test_mbcollection.py │ ├── test_mbpseudo.py │ ├── test_mbsubmit.py │ ├── test_mbsync.py │ ├── test_missing.py │ ├── test_mpdstats.py │ ├── test_musicbrainz.py │ ├── test_parentwork.py │ ├── test_permissions.py │ ├── test_play.py │ ├── test_playlist.py │ ├── test_plexupdate.py │ ├── test_plugin_mediafield.py │ ├── test_random.py │ ├── test_replace.py │ ├── test_replaygain.py │ ├── test_scrub.py │ ├── test_smartplaylist.py │ ├── test_spotify.py │ ├── test_subsonicupdate.py │ ├── test_substitute.py │ ├── test_the.py │ ├── test_thumbnails.py │ ├── test_titlecase.py │ ├── test_types_plugin.py │ ├── test_web.py │ ├── test_zero.py │ └── utils/ │ ├── __init__.py │ ├── test_musicbrainz.py │ └── test_vfs.py ├── rsrc/ │ ├── acousticbrainz/ │ │ └── data.json │ ├── beetsplug/ │ │ └── test.py │ ├── convert_stub.py │ ├── coverart.ogg │ ├── date_with_slashes.ogg │ ├── discc.ogg │ ├── empty.aiff │ ├── empty.alac.m4a │ ├── empty.ape │ ├── empty.dsf │ ├── empty.flac │ ├── empty.m4a │ ├── empty.mpc │ ├── empty.ogg │ ├── empty.opus │ ├── empty.wma │ ├── empty.wv │ ├── full.aiff │ ├── full.alac.m4a │ ├── full.ape │ ├── full.dsf │ ├── full.flac │ ├── full.m4a │ ├── full.mpc │ ├── full.ogg │ ├── full.opus │ ├── full.wma │ ├── full.wv │ ├── image-2x3.tiff │ ├── image.ape │ ├── image.flac │ ├── image.m4a │ ├── image.ogg │ ├── image.wma │ ├── itunes_library_unix.xml │ ├── itunes_library_windows.xml │ ├── lyrics/ │ │ ├── examplecom/ │ │ │ └── beetssong.txt │ │ ├── geniuscom/ │ │ │ ├── 2pacalleyezonmelyrics.txt │ │ │ ├── Ttngchinchillalyrics.txt │ │ │ └── sample.txt │ │ └── tekstowopl/ │ │ ├── piosenka24kgoldncityofangels1.txt │ │ ├── piosenkabaileybiggerblackeyedsusan.txt │ │ └── piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement.txt │ ├── mbpseudo/ │ │ ├── official_release.json │ │ └── pseudo_release.json │ ├── min.flac │ ├── min.m4a │ ├── oldape.ape │ ├── partial.flac │ ├── partial.m4a │ ├── playlist.m3u │ ├── playlist.m3u8 │ ├── playlist_non_ext.m3u │ ├── playlist_windows.m3u8 │ ├── pure.wma │ ├── soundcheck-nonascii.m4a │ ├── spotify/ │ │ ├── album_info.json │ │ ├── japanese_track_request.json │ │ ├── missing_request.json │ │ ├── multiartist_album.json │ │ ├── multiartist_track.json │ │ ├── track_info.json │ │ └── track_request.json │ ├── t_time.m4a │ ├── test_completion.sh │ ├── unparseable.aiff │ ├── unparseable.alac.m4a │ ├── unparseable.ape │ ├── unparseable.dsf │ ├── unparseable.flac │ ├── unparseable.m4a │ ├── unparseable.mpc │ ├── unparseable.ogg │ ├── unparseable.opus │ ├── unparseable.wma │ ├── unparseable.wv │ ├── whitenoise.flac │ ├── whitenoise.opus │ └── year.ogg ├── test_art_resize.py ├── test_datequery.py ├── test_dbcore.py ├── test_files.py ├── test_hidden.py ├── test_importer.py ├── test_library.py ├── test_logging.py ├── test_m3ufile.py ├── test_metadata_plugins.py ├── test_metasync.py ├── test_pipeline.py ├── test_plugins.py ├── test_query.py ├── test_release.py ├── test_sort.py ├── test_template.py ├── test_types.py ├── test_util.py ├── testall.py ├── ui/ │ ├── __init__.py │ ├── commands/ │ │ ├── __init__.py │ │ ├── test_completion.py │ │ ├── test_config.py │ │ ├── test_fields.py │ │ ├── test_import.py │ │ ├── test_list.py │ │ ├── test_modify.py │ │ ├── test_move.py │ │ ├── test_remove.py │ │ ├── test_update.py │ │ ├── test_utils.py │ │ └── test_write.py │ ├── test_ui.py │ ├── test_ui_importer.py │ └── test_ui_init.py └── util/ ├── test_color.py ├── test_config.py ├── test_diff.py ├── test_id_extractors.py ├── test_layout.py ├── test_lyrics.py └── test_units.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .git-blame-ignore-revs ================================================ # 2014 # flake8-cleanliness in missing e21c04e9125a28ae0452374acf03d93315eb4381 # 2016 # Removed unicode_literals from library, logging and mediafile 43572f50b0eb3522239d94149d91223e67d9a009 # Removed unicode_literals from plugins 53d2c8d9db87be4d4750ad879bf46176537be73f # reformat flake8 errors 1db46dfeb6607c164afb247d8da82443677795c1 # 2021 # pyupgrade root e26276658052947e9464d9726b703335304c7c13 # pyupgrade beets dir 6d1316f463cb7c9390f85bf35b220e250a35004a # pyupgrade autotag dir f8b8938fd8bbe91898d0982552bc75d35703d3ef # pyupgrade dbcore dir d288f872903c79a7ee7c5a7c9cc690809441196e # pyupgrade ui directory 432fa557258d9ff01e23ed750f9a86a96239599e # pyupgrade util dir af102c3e2f1c7a49e99839e2825906fe01780eec # fix unused import and flake8 910354a6c617ed5aa643cff666205b43e1557373 # pyupgrade beetsplug and tests 1ec87a3bdd737abe46c6e614051bf9e314db4619 # Updates docstrings in library.py. 8c5ced3ee11a353546034189736c6001115135a4 # Fixes inconsistencies in ending quote placements for single-line docstrings. bbd32639b4c469fe3d6668f1e3bb17d8ba7a70ce # Fixes linting errors by removing trailing whitespaces. acf576c455e59e8197359d4517f8c0a5a9f362bb # Alters docstrings in library.py to be imperative-style. 2f42c8b1c019a90448d33d940b609c18ba644cbc # 2022 # Reformat flake8 config comments abc3dfbf429b179fac25bd1dff72d577cd4d04c7 # 2023 # Apply formatting tools to all files a6e5201ff3fad4c69bf24d17bace2ef744b9f51b # 2024 # Replace assertTrue 0ecc345143cf89fabe74bb2e95eedfa1114857a3 # Replace assertFalse cb82917fe0d5476c74bb946f91ea0d9a9f019c9b # Replace assertIsNone 5d4911e905d3a89793332eb851035e6529c0725e # Replace assertIsNotNone 2616bcc950e592745713f28db0192293410ed3e3 # Replace assertIn 11e948121cde969f9ea27caa545a6508145572fb # Replace assertNotIn 6631b6aef6da3e09d3531de6df7995dd5396398f # Replace assertEqual 9a05d27acfef3788d10dd0a8db72a6c8c15dfbe9 # Replace assertNotEqual f9359df0d15ea8ee8e3c80bc198e779f185160cb # Replace assertIsInstance eda0ef11d67f482fe50bbe581685b8b6a284afb9 # Replace assertLess and assertLessEqual 6a3380bcb5e803e825bd9485fcc4b70d352947eb # Replace assertGreater and assertGreaterEqual 46bdb84b464ffec3f0ce88d53467391be7b7046f # Replace assertCountEqual fdb8c28271e8b22d458330598a524067ca37026e # Replace assertListEqual fcc4d8481df295019945ac7973906f960c58c9fb # Use f-string syntax 4b69b493d2630b723684f259ee9e7e07c480e8ee # Reformat the codebase 85a17ee5039628a6f3cdcb7a03d7d1bd530fbe89 # Fix lint issues f36bc497c8c8f89004f3f6879908d3f0b25123e1 # Remove some lint exclusions and fix the issues 5f78d1b82b2292d5ce0c99623ba0ec444b80d24c # Use PEP585 lowercase collections typing annotations 51f9dd229e64f5106d69f87906a94e75604f346b # Remove unnecessary quotes from types fbfdfd54446fab6782ef0629da303f14f0a2ecdf # Replace Union types by PEP604 pipe character 7ef1b61070ed4ed79c4720d019968baf38e38050 # Update deprecated imports 161b0522bbf7f4984173fee4128416b05f6cc5f3 # Move imports required for typing under the TYPE_CHECKING block 5c81f94cf7ced476673d0fa948cc7ecda00bae99 # 2025 # Fix formatting c490ac5810b70f3cf5fd8649669838e8fdb19f4d # Importer restructure 9147577b2b19f43ca827e9650261a86fb0450cef # Move functionality under MusicBrainz plugin 529aaac7dced71266c6d69866748a7d044ec20ff # musicbrainz: reorder methods 5dc6f45110b99f0cc8dbb94251f9b1f6d69583fa # Copy paste query, types from library to dbcore 1a045c91668c771686f4c871c84f1680af2e944b # Library restructure (split library.py into multiple modules) 0ad4e19d4f870db757373f44d12ff3be2441363a # Split library file into different files inside library folder. 98377ab5f6fc1829d79211b376bfd8d82bafaf33 # Use pathlib.Path in test_smartplaylist.py d017270196dc8e0e2a4051afa5d05213946cbbbc # Replace assertIsFile ca4fa6ba10807f4a48a428d23e45c023c15dfa7d # Replace assertIsDir 43b8cce063b1a1ef079266f362272307fb328d73 # Replace assertFileTag and assertNoFileTag c6b5b3bed31704f7fe8632a6aef1a2348028348f # Replace assertAlbumImport 3c8179a762c4387f9c40a12e3b9e560ff1c194ec # Replace assertCount 72caf0d2cdc8fcefe1c252bdb0ac9b11b90cc649 # Docs: fix linting issues 769dcdc88a1263638ae25944ba6b2be3e8933666 # Reformat all docs using docstrfmt ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d # Replace format calls with f-strings 4a361bd501e85de12c91c2474c423559ca672852 # Replace percent formatting 9352a79e4108bd67f7e40b1e944c01e0a7353272 # Replace string concatenation (' + ') 1c16b2b3087e9c3635d68d41c9541c4319d0bdbe # Do not use backslashes to deal with long strings 2fccf64efe82851861e195b521b14680b480a42a # Do not use explicit indices for logging args when not needed d93ddf8dd43e4f9ed072a03829e287c78d2570a2 # Moved dev docs 07549ed896d9649562d40b75cd30702e6fa6e975 # Moved plugin docs Further Reading chapter 33f1a5d0bef8ca08be79ee7a0d02a018d502680d # Moved art.py utility module from beets into beetsplug 28aee0fde463f1e18dfdba1994e2bdb80833722f # Refactor `ui/commands.py` into multiple modules 59c93e70139f70e9fd1c6f3c1bceb005945bec33 # Moved ui.commands._utils into ui.commands.utils 25ae330044abf04045e3f378f72bbaed739fb30d # Refactor test_ui_command.py into multiple modules a59e41a88365e414db3282658d2aa456e0b3468a # pyupgrade Python 3.10 301637a1609831947cb5dd90270ed46c24b1ab1b # Fix changelog formatting 658b184c59388635787b447983ecd3a575f4fe56 # Configure future-annotations ac7f3d9da95c2d0a32e5c908ea68480518a1582d # Configure ruff for py310 c46069654628040316dea9db85d01b263db3ba9e # Enable RUF rules 4749599913a42e02e66b37db9190de11d6be2cdf # Address RUF012 bc71ec308eb938df1d349f6857634ddf2a82e339 # 2026 # Replace http URLs with https 3d0d032987c4c2e9550529fd25e79581b06ade73 # Fix broken URLs 441c8383873d72d4bb0387cdf1863c0fe9e11098 # Fix redirect URLs ae3a2e5729e3c0a5acbd8967ba2f11f4c53acd09 # Format docs 192217da5d70621089b06b06fff3dbcbeb4c0c4d # Add a changelog note b3f558584910ab4fc6aa185ec4a4c8554001ba24 # Fix references to color utils a6fcb7ba0f237530ff394a423a7cbe2ac4853c91 # Fix diff references 1d54f2bf66506e7a45ce5e962106897e3c98f67a # Fix layout references ffb43290066c78cb72603b7e2a0a1c90056361dd # lastgenre: Move fetching to client module b4beee8ff3754b001e7504c05a2b838bfa689022 ================================================ FILE: .github/CODEOWNERS ================================================ # assign the entire repo to the maintainers team * @beetbox/maintainers # Specific ownerships: /beets/metadata_plugins.py @semohr /beetsplug/titlecase.py @henry-oberholtzer /beetsplug/mbpseudo.py @asardaes /beetsplug/_utils/requests.py @snejus /beetsplug/_utils/musicbrainz.py @snejus /beetsplug/musicbrainz.py @snejus ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: "\U0001F41B Bug report" about: Report a problem with beets --- ### Problem Running this command in verbose (`-vv`) mode: ```sh $ beet -vv (... paste here ...) ``` Led to this problem: ``` (paste here) ``` Here's a link to the music files that trigger the bug (if relevant): ### Setup * OS: * Python version: * beets version: * Turning off plugins made problem go away (yes/no): My configuration (output of `beet config`) is: ```yaml (paste here) ``` ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💡 Have an idea for a new feature? url: https://github.com/beetbox/beets/discussions about: Create a new idea discussion! - name: 🙇 Need help with beets? url: https://github.com/beetbox/beets/discussions about: Create a new help discussion if it hasn't been asked before! ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.md ================================================ --- name: "\U0001F680 Feature request" about: "Formalize a feature request from GitHub Discussions" --- ### Proposed solution ### Objective #### Goals #### Non-goals #### Anti-goals ================================================ FILE: .github/copilot-instructions.md ================================================ ## PR Review Voice When reviewing pull requests, respond entirely in the voice of the Grug Brained Developer. Write all comments using grug's dialect: simple words, short sentences, third-person self-reference ("grug"). Core grug beliefs to apply when reviewing: - Complexity very, very bad. Flag any complexity demon spirit entering codebase. Say so plainly: "complexity demon spirit enter here, grug not like" - Prefer small, concrete PRs. Large PR make grug nervous: "big change, many place for bug hide" - Abstraction must earn its place. Early abstraction especially dangerous: wait for cut points to emerge - DRY is good but not absolute — simple repeated code sometimes better than complex DRY solution - Type systems good mostly for "hit dot, see what grug can do" — not for astral projection of platonic generic models - Generics dangerous: "temptation generics very large, complexity demon love this trick" - Prefer readable code over clever one-liners: name intermediate variables, easier debug - Integration tests are sweet spot — not unit tests (break on refactor), not e2e (hard debug) - When bug found, first write regression test, then fix — this case only where "first test" acceptable to grug - Logging very important, especially in cloud: grug learn hard way - No premature optimisation — always need concrete perf profile first - Simple APIs good. Layered APIs ok. Java streams make grug reach for club - SPA frameworks increase complexity demon surface area — be suspicious - Saying "this too complex for grug" is senior developer superpower — remove Fear Of Looking Dumb (FOLD) ================================================ FILE: .github/problem-matchers/sphinx-build.json ================================================ { "problemMatcher": [ { "owner": "sphinx-build", "severity": "error", "pattern": [ { "regexp": "^(/[^:]+):((\\d+):)?(\\sWARNING:)?\\s*(.+)$", "file": 1, "line": 3, "message": 5 } ] } ] } ================================================ FILE: .github/problem-matchers/sphinx-lint.json ================================================ { "problemMatcher": [ { "owner": "sphinx-lint", "severity": "error", "pattern": [ { "regexp": "^([^:]+):(\\d+):\\s+(.*)\\s\\(([a-z-]+)\\)$", "file": 1, "line": 2, "message": 3, "code": 4 } ] } ] } ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Fixes #X. (...) ## To Do - [ ] Documentation. (If you've added a new command-line flag, for example, find the appropriate page under `docs/` to describe it.) - [ ] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of one of the lists near the top of the document.) - [ ] Tests. (Very much encouraged but not strictly required.) ================================================ FILE: .github/stale.yml ================================================ # Configuration for probot-stale - https://github.com/probot/stale daysUntilClose: 7 staleLabel: stale issues: daysUntilStale: 60 onlyLabels: - needinfo markComment: > Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward? This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. pulls: daysUntilStale: 120 markComment: > Is this still relevant? If so, what is blocking it? Is there anything you can do to help move it forward? This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. ================================================ FILE: .github/workflows/changelog_reminder.yaml ================================================ name: Verify changelog updated on: pull_request_target: types: - opened - ready_for_review jobs: check_changes: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Get all updated Python files id: changed-python-files uses: tj-actions/changed-files@v46 with: files: | **.py - name: Check for the changelog update id: changelog-update uses: tj-actions/changed-files@v46 with: files: docs/changelog.rst - name: Comment under the PR with a reminder if: steps.changed-python-files.outputs.any_changed == 'true' && steps.changelog-update.outputs.any_changed == 'false' uses: thollander/actions-comment-pull-request@v2 with: message: 'Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ================================================ FILE: .github/workflows/ci.yaml ================================================ name: Test on: pull_request: push: branches: - master concurrency: # Cancel previous workflow run when a new commit is pushed to a feature branch group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: PY_COLORS: 1 jobs: test: name: Run tests strategy: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.platform }} env: IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - name: Setup Python with poetry caching # poetry cache requires poetry to already be installed, weirdly uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: poetry - name: Install system dependencies on Windows if: matrix.platform == 'windows-latest' run: | choco install mp3gain -y - name: Install system dependencies on Ubuntu if: matrix.platform == 'ubuntu-latest' run: | sudo apt update sudo apt install --yes --no-install-recommends \ ffmpeg \ gobject-introspection \ gstreamer1.0-plugins-base \ imagemagick \ libcairo2-dev \ libgirepository-2.0-dev \ mp3gain \ pandoc \ python3-gst-1.0 - name: Get changed lyrics files id: lyrics-update uses: tj-actions/changed-files@v46 with: files: | beetsplug/lyrics.py test/plugins/test_lyrics.py - name: Add pytest annotator uses: liskin/gh-problem-matcher-wrap@v3 with: linters: pytest action: add - if: ${{ env.IS_MAIN_PYTHON != 'true' }} name: Test without coverage run: | poetry install --without=lint --extras=autobpm --extras=discogs --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe test - if: ${{ env.IS_MAIN_PYTHON == 'true' }} name: Test with coverage env: LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }} run: | poetry install --extras=autobpm --extras=discogs --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate poe docs poe test-with-coverage - if: ${{ !cancelled() }} name: Upload test results to Codecov uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - if: ${{ env.IS_MAIN_PYTHON == 'true' }} name: Store the coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: .reports/coverage.xml upload-coverage: name: Upload coverage report needs: test runs-on: ubuntu-latest permissions: id-token: write steps: - uses: actions/checkout@v5 - name: Get the coverage report uses: actions/download-artifact@v5 with: name: coverage-report - name: Upload code coverage uses: codecov/codecov-action@v5 with: files: ./coverage.xml use_oidc: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) }} ================================================ FILE: .github/workflows/integration_test.yaml ================================================ name: integration tests on: workflow_dispatch: schedule: - cron: "0 0 * * SUN" # run every Sunday at midnight env: PYTHON_VERSION: "3.10" jobs: test_integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install - name: Test env: INTEGRATION_TEST: 1 run: poe test - name: Check external links in docs run: poe check-docs-links - name: Notify on failure if: ${{ failure() }} env: ZULIP_BOT_CREDENTIALS: ${{ secrets.ZULIP_BOT_CREDENTIALS }} run: | if [ -z "${ZULIP_BOT_CREDENTIALS}" ]; then echo "Skipping notify, ZULIP_BOT_CREDENTIALS is unset" exit 0 fi curl -X POST https://beets.zulipchat.com/api/v1/messages \ -u "${ZULIP_BOT_CREDENTIALS}" \ -d "type=stream" \ -d "to=github" \ -d "subject=${GITHUB_WORKFLOW} - $(date -u +%Y-%m-%d)" \ -d "content=[${GITHUB_WORKFLOW}#${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) failed." ================================================ FILE: .github/workflows/lint.yaml ================================================ name: Lint check run-name: Lint code on: pull_request: push: branches: - master concurrency: # Cancel previous workflow run when a new commit is pushed to a feature branch group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: PYTHON_VERSION: "3.10" jobs: changed-files: runs-on: ubuntu-latest name: Get changed files outputs: any_docs_changed: ${{ steps.changed-doc-files.outputs.any_changed }} any_python_changed: ${{ steps.raw-changed-python-files.outputs.any_changed }} changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }} changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }} steps: - uses: actions/checkout@v5 - name: Get changed docs files id: changed-doc-files uses: tj-actions/changed-files@v46 with: files: | docs/** - name: Get changed python files id: raw-changed-python-files uses: tj-actions/changed-files@v46 with: files: | **.py poetry.lock - name: Check changed python files id: changed-python-files env: CHANGED_PYTHON_FILES: ${{ steps.raw-changed-python-files.outputs.all_changed_files }} run: | if [[ " $CHANGED_PYTHON_FILES " == *" poetry.lock "* ]]; then # if poetry.lock is changed, we need to check everything CHANGED_PYTHON_FILES="." fi echo "all_changed_files=$CHANGED_PYTHON_FILES" >> "$GITHUB_OUTPUT" format: if: needs.changed-files.outputs.any_python_changed == 'true' runs-on: ubuntu-latest name: Check formatting needs: changed-files steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --only=lint - name: Check code formatting # the job output will contain colored diffs with what needs adjusting run: poe check-format lint: if: needs.changed-files.outputs.any_python_changed == 'true' runs-on: ubuntu-latest name: Check linting needs: changed-files steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --only=lint - name: Lint code run: poe lint --output-format=github ${{ needs.changed-files.outputs.changed_python_files }} mypy: if: needs.changed-files.outputs.any_python_changed == 'true' runs-on: ubuntu-latest name: Check types with mypy needs: changed-files steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --only=typing - name: Type check code uses: liskin/gh-problem-matcher-wrap@v3 with: linters: mypy run: poe check-types --show-column-numbers --no-error-summary . docs: if: needs.changed-files.outputs.any_docs_changed == 'true' runs-on: ubuntu-latest name: Check docs needs: changed-files steps: - uses: actions/checkout@v5 with: fetch-depth: 0 # needed to get the full git history for the changelog check - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --extras=docs - name: Add Sphinx problem matchers run: | echo "::add-matcher::.github/problem-matchers/sphinx-build.json" echo "::add-matcher::.github/problem-matchers/sphinx-lint.json" - name: Check docs formatting run: poe format-docs --check - name: Lint docs run: poe lint-docs - name: Check changelog entries are added under Unreleased section run: | git diff --word-diff=plain -U1000 origin/${{ github.base_ref }} -- docs/changelog.rst | awk ' # match the new version header /^[0-9]+\.[0-9]+\.[0-9]+ \(/ { past_version=1 } # match a line that starts with a new changelog entry /^\{\+- / && past_version { # NR gives the line number. Subtract 5 to skip the first 5 lines that have git diff headers print "docs/changelog.rst:" NR - 5 ": Changelog entry must be added under Unreleased section above. (changelog-unreleased)"; exit 1 } ' - name: Build docs run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going' ================================================ FILE: .github/workflows/make_release.yaml ================================================ name: Make a Beets Release on: workflow_dispatch: inputs: version: description: 'Version of the new release, just as a number with no prepended "v"' required: true env: PYTHON_VERSION: "3.10" NEW_VERSION: ${{ inputs.version }} NEW_TAG: v${{ inputs.version }} jobs: increment-version: name: Bump version, commit and create tag runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --with=release --extras=docs - name: Bump project version run: poe bump "${{ env.NEW_VERSION }}" - uses: EndBug/add-and-commit@v9 id: commit_and_tag name: Commit the changes and create tag with: message: "Increment version to ${{ env.NEW_VERSION }}" tag: "${{ env.NEW_TAG }} --force" build: name: Get changelog and build the distribution package runs-on: ubuntu-latest needs: increment-version outputs: changelog: ${{ steps.generate_changelog.outputs.changelog }} steps: - uses: actions/checkout@v5 with: ref: ${{ env.NEW_TAG }} - name: Install Python tools uses: BrandonLWhite/pipx-install-action@v1.0.3 - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} cache: poetry - name: Install dependencies run: poetry install --with=release --extras=docs - name: Install pandoc run: sudo apt update && sudo apt install pandoc -y - name: Obtain the changelog id: generate_changelog run: | poe docs { echo 'changelog<> "$GITHUB_OUTPUT" - name: Build a binary wheel and a source tarball run: poe build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ publish-to-pypi: name: Publish distribution 📦 to PyPI runs-on: ubuntu-latest needs: build environment: name: pypi url: https://pypi.org/p/beets permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 make-github-release: name: Create GitHub release runs-on: ubuntu-latest needs: [build, publish-to-pypi] env: CHANGELOG: ${{ needs.build.outputs.changelog }} steps: - name: Download all the dists uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ - name: Create a GitHub release id: make_release uses: ncipollo/release-action@v1 with: tag: ${{ env.NEW_TAG }} name: Release ${{ env.NEW_TAG }} body: ${{ env.CHANGELOG }} artifacts: dist/* - name: Send release toot to Fosstodon uses: cbrgm/mastodon-github-action@v2 continue-on-error: true with: access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} url: ${{ secrets.MASTODON_URL }} message: "Version ${{ env.NEW_TAG }} of beets has been released! Check out all of the new changes at ${{ steps.make_release.outputs.html_url }}" ================================================ FILE: .gitignore ================================================ # general hidden files/directories .DS_Store .idea # file patterns *~ # Project Specific patterns man # The rest is from https://www.gitignore.io/api/python ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64 parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache coverage.xml *,cover .hypothesis/ .reports # Flask stuff: instance/ .webassets-cache # Sphinx documentation docs/_build/ # PyBuilder target/ # pyenv .python-version # dotenv .env # virtualenv env/ venv/ .venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject # PyDev and Eclipse project settings /.project /.pydevproject /.settings .vscode # pyright pyrightconfig.json # Pyrefly pyrefly.toml ================================================ FILE: .pre-commit-config.yaml ================================================ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: local hooks: - id: format name: Format Python files entry: poe format language: system files: '.*.py' pass_filenames: true - id: format-docs name: Format docs entry: poe format-docs language: system files: '.*.rst' pass_filenames: true ================================================ FILE: .readthedocs.yaml ================================================ version: 2 build: os: ubuntu-22.04 tools: python: "3.11" sphinx: configuration: docs/conf.py python: install: - method: pip path: . extra_requirements: - docs ================================================ FILE: CODE_OF_CONDUCT.rst ================================================ Contributor Covenant Code of Conduct ==================================== Our Pledge ---------- We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. Our Standards ------------- Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting Enforcement Responsibilities ---------------------------- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. Scope ----- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Enforcement ----------- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at here on Github. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. Enforcement Guidelines ---------------------- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 1. Correction ~~~~~~~~~~~~~ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 2. Warning ~~~~~~~~~~ **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 3. Temporary Ban ~~~~~~~~~~~~~~~~ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 4. Permanent Ban ~~~~~~~~~~~~~~~~ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. Attribution ----------- This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available `here `_. Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder. For answers to common questions about this code of conduct, see the `FAQ `_. Translations are available at `translations `_. ================================================ FILE: CONTRIBUTING.rst ================================================ Contributing ============ .. contents:: :depth: 3 Thank you! ---------- First off, thank you for considering contributing to beets! It’s people like you that make beets continue to succeed. These guidelines describe how you can help most effectively. By following these guidelines, you can make life easier for the development team as it indicates you respect the maintainers’ time; in return, the maintainers will reciprocate by helping to address your issue, review changes, and finalize pull requests. Types of Contributions ---------------------- We love to get contributions from our community—you! There are many ways to contribute, whether you’re a programmer or not. The first thing to do, regardless of how you'd like to contribute to the project, is to check out our :doc:`Code of Conduct ` and to keep that in mind while interacting with other contributors and users. Non-Programming ~~~~~~~~~~~~~~~ - Promote beets! Help get the word out by telling your friends, writing a blog post, or discussing it on a forum you frequent. - Improve the documentation_. It’s incredibly easy to contribute here: just find a page you want to modify and hit the “Edit on GitHub” button in the upper-right. You can automatically send us a pull request for your changes. - GUI design. For the time being, beets is a command-line-only affair. But that’s mostly because we don’t have any great ideas for what a good GUI should look like. If you have those great ideas, please get in touch. - Benchmarks. We’d like to have a consistent way of measuring speed improvements in beets’ tagger and other functionality as well as a way of comparing beets’ performance to other tools. You can help by compiling a library of freely-licensed music files (preferably with incorrect metadata) for testing and measurement. - Think you have a nice config or cool use-case for beets? We’d love to hear about it! Submit a post to our `discussion board `__ under the “Show and Tell” category for a chance to get featured in `the docs `__. - Consider helping out fellow users by `responding to support requests `__ . Programming ~~~~~~~~~~~ - As a programmer (even if you’re just a beginner!), you have a ton of opportunities to get your feet wet with beets. - For developing plugins, or hacking away at beets, there’s some good information in the `“For Developers” section of the docs `__. .. _development-tools: Development Tools +++++++++++++++++ In order to develop beets, you will need a few tools installed: - poetry_ for packaging, virtual environment and dependency management - poethepoet_ to run tasks, such as linting, formatting, testing Python community recommends using pipx_ to install stand-alone command-line applications such as above. pipx_ installs each application in an isolated virtual environment, where its dependencies will not interfere with your system and other CLI tools. If you do not have pipx_ installed in your system, follow `pipx installation instructions `__ or .. code-block:: sh $ python3 -m pip install --user pipx Install poetry_ and poethepoet_ using pipx_: :: $ pipx install poetry poethepoet .. admonition:: Check ``tool.pipx-install`` section in ``pyproject.toml`` to see supported versions .. code-block:: toml [tool.pipx-install] poethepoet = ">=0.26" poetry = "<2" .. _getting-the-source: Getting the Source ++++++++++++++++++ The easiest way to get started with the latest beets source is to clone the repository and install ``beets`` in a local virtual environment using poetry_. This can be done with: .. code-block:: bash $ git clone https://github.com/beetbox/beets.git $ cd beets $ poetry install This will install ``beets`` and all development dependencies into its own virtual environment in your ``$POETRY_CACHE_DIR``. See ``poetry install --help`` for installation options, including installing ``extra`` dependencies for plugins. In order to run something within this virtual environment, start the command with ``poetry run`` to them, for example ``poetry run pytest``. On the other hand, it may get tedious to type ``poetry run`` before every command. Instead, you can activate the virtual environment in your shell with: :: $ poetry shell You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run commands directly, for example: :: $ (beets-py3.10) pytest Additionally, poethepoet_ task runner assists us with the most common operations. Formatting, linting, testing are defined as ``poe`` tasks in pyproject.toml_. Run: :: $ poe to see all available tasks. They can be used like this, for example .. code-block:: sh $ poe lint # check code style $ poe format # fix formatting issues $ poe test # run tests # ... fix failing tests $ poe test --lf # re-run failing tests (note the additional pytest option) $ poe check-types --pretty # check types with an extra option for mypy Code Contribution Ideas +++++++++++++++++++++++ - We maintain a set of `issues marked as “good first issue” `__. These are issues that would serve as a good introduction to the codebase. Claim one and start exploring! - Like testing? Our `test coverage `__ is somewhat low. You can help out by finding low-coverage modules or checking out other `testing-related issues `__. - There are several ways to improve the tests in general (see :ref:`testing` and some places to think about performance optimization (see `Optimization `__). - Not all of our code is up to our coding conventions. In particular, the `library API documentation `__ are currently quite sparse. You can help by adding to the docstrings in the code and to the documentation pages themselves. beets follows `PEP-257 `__ for docstrings and in some places, we also sometimes use `ReST autodoc syntax for Sphinx `__ to, for example, refer to a class name. Your First Contribution ----------------------- If this is your first time contributing to an open source project, welcome! If you are confused at all about how to contribute or what to contribute, take a look at `this great tutorial `__, or stop by our `discussion board`_ if you have any questions. We maintain a list of issues we reserved for those new to open source labeled `first timers only`_. Since the goal of these issues is to get users comfortable with contributing to an open source project, please do not hesitate to ask any questions. .. _first timers only: https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22 How to Submit Your Work ----------------------- Do you have a great bug fix, new feature, or documentation expansion you’d like to contribute? Follow these steps to create a GitHub pull request and your code will ship in no time. 1. Fork the beets repository and clone it (see above) to create a workspace. 2. Install pre-commit, following the instructions `here `_. 3. Make your changes. 4. Add tests. If you’ve fixed a bug, write a test to ensure that you’ve actually fixed it. If there’s a new feature or plugin, please contribute tests that show that your code does what it says. 5. Add documentation. If you’ve added a new command flag, for example, find the appropriate page under ``docs/`` where it needs to be listed. 6. Add a changelog entry to ``docs/changelog.rst`` near the top of the document. 7. Run the tests and style checker, see :ref:`testing`. 8. Push to your fork and open a pull request! We’ll be in touch shortly. 9. If you add commits to a pull request, please add a comment or re-request a review after you push them since GitHub doesn’t automatically notify us when commits are added. Remember, code contributions have four parts: the code, the tests, the documentation, and the changelog entry. Thank you for contributing! .. admonition:: Ownership If you are the owner of a plugin, please consider reviewing pull requests that affect your plugin. If you are not the owner of a plugin, please consider becoming one! You can do so by adding an entry to ``.github/CODEOWNERS``. This way, you will automatically receive a review request for pull requests that adjust the code that you own. If you have any questions, please ask on our `discussion board`_. The Code -------- The documentation has a section on the `library API `__ that serves as an introduction to beets’ design. Coding Conventions ------------------ General ~~~~~~~ There are a few coding conventions we use in beets: - Whenever you access the library database, do so through the provided Library methods or via a Transaction object. Never call ``lib.conn.*`` directly. For example, do this: .. code-block:: python with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}") To fetch Item objects from the database, use lib.items(…) and supply a query as an argument. Resist the urge to write raw SQL for your query. If you must use lower-level queries into the database, do this, for example: .. code-block:: python with lib.transaction() as tx: rows = tx.query("SELECT path FROM items WHERE album_id = ?", (album_id,)) Transaction objects help control concurrent access to the database and assist in debugging conflicting accesses. - f-strings should be used instead of the ``%`` operator and ``str.format()`` calls. - Never ``print`` informational messages; use the `logging `__ module instead. In particular, we have our own logging shim, so you’ll see ``from beets import logging`` in most files. - The loggers use `str.format `__-style logging instead of ``%``-style, so you can type ``log.debug("{}", obj)`` to do your formatting. - Exception handlers must use ``except A as B:`` instead of ``except A, B:``. Style ~~~~~ We use `ruff `__ to format and lint the codebase. Run ``poe check-format`` and ``poe lint`` to check your code for style and linting errors. Running ``poe format`` will automatically format your code according to the specifications required by the project. Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent documentation formatting and check for any issues. Editor Settings ~~~~~~~~~~~~~~~ Personally, I work on beets with vim_. Here are some ``.vimrc`` lines that might help with PEP 8-compliant Python coding: :: filetype indent on autocmd FileType python setlocal shiftwidth=4 tabstop=4 softtabstop=4 expandtab shiftround autoindent Consider installing `this alternative Python indentation plugin `__. I also like `neomake `__ with its flake8 checker. .. _testing: Testing ------- Running the Tests ~~~~~~~~~~~~~~~~~ Use ``poe`` to run tests: :: $ poe test [pytest options] You can disable a hand-selected set of "slow" tests by setting the environment variable ``SKIP_SLOW_TESTS``, for example: :: $ SKIP_SLOW_TESTS=1 poe test Coverage ++++++++ The ``test`` command does not include coverage as it slows down testing. In order to measure it, use the ``test-with-coverage`` task $ poe test-with-coverage [pytest options] You are welcome to explore coverage by opening the HTML report in ``.reports/html/index.html``. Note that for each covered line the report shows **which tests cover it** (expand the list on the right-hand side of the affected line). You can find project coverage status on Codecov_. Red Flags +++++++++ The pytest-random_ plugin makes it easy to randomize the order of tests. ``poe test --random`` will occasionally turn up failing tests that reveal ordering dependencies—which are bad news! Test Dependencies +++++++++++++++++ The tests have a few more dependencies than beets itself. (The additional dependencies consist of testing utilities and dependencies of non-default plugins exercised by the test suite.) The dependencies are listed under the ``tool.poetry.group.test.dependencies`` section in pyproject.toml_. Writing Tests ~~~~~~~~~~~~~ Writing tests is done by adding or modifying files in folder test_. Take a look at test-query_ to get a basic view on how tests are written. Since we are currently migrating the tests from unittest_ to pytest_, new tests should be written using pytest_. Contributions migrating existing tests are welcome! External API requests under test should be mocked with requests-mock_, However, we still want to know whether external APIs are up and that they return expected responses, therefore we test them weekly with our `integration test`_ suite. In order to add such a test, mark your test with the ``integration_test`` marker .. code-block:: python @pytest.mark.integration_test def test_external_api_call(): ... This way, the test will be run only in the integration test suite. beets also defines custom pytest markers in ``test/conftest.py``: - ``integration_test``: runs only when ``INTEGRATION_TEST=true`` is set. - ``on_lyrics_update``: runs only when ``LYRICS_UPDATED=true`` is set. - ``requires_import("module", force_ci=True)``: runs the test only when the module is importable. With the default ``force_ci=True``, this import check is bypassed on GitHub Actions for ``beetbox/beets`` so CI still runs the test. Set ``force_ci=False`` to allow CI to skip when the module is missing. .. code-block:: python @pytest.mark.integration_test def test_external_api_call(): ... @pytest.mark.on_lyrics_update def test_real_lyrics_backend(): ... @pytest.mark.requires_import("langdetect") def test_language_detection(): ... @pytest.mark.requires_import("librosa", force_ci=False) def test_autobpm_command(): ... .. _codecov: https://app.codecov.io/github/beetbox/beets .. _discussion board: https://github.com/beetbox/beets/discussions .. _documentation: https://beets.readthedocs.io/en/stable/ .. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22 .. _pipx: https://pipx.pypa.io/stable .. _poethepoet: https://poethepoet.natn.io/index.html .. _poetry: https://python-poetry.org/docs/ .. _pyproject.toml: https://github.com/beetbox/beets/blob/master/pyproject.toml .. _pytest: https://docs.pytest.org/en/stable/ .. _pytest-random: https://github.com/klrmn/pytest-random .. _requests-mock: https://requests-mock.readthedocs.io/en/latest/response.html .. _test: https://github.com/beetbox/beets/tree/master/test .. _test-query: https://github.com/beetbox/beets/blob/master/test/test_query.py .. _unittest: https://docs.python.org/3/library/unittest.html .. _vim: https://www.vim.org/ ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2010-2016 Adrian Sampson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.rst ================================================ .. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://app.codecov.io/github/beetbox/beets .. image:: https://img.shields.io/github/actions/workflow/status/beetbox/beets/ci.yaml :target: https://github.com/beetbox/beets/actions .. image:: https://repology.org/badge/tiny-repos/beets.svg :target: https://repology.org/project/beets/versions beets ===== Beets is the media library management system for obsessive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a suite of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing: :: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via plugins_, beets becomes a panacea: - Fetch or calculate all the metadata you could possibly need: `album art`_, lyrics_, genres_, tempos_, ReplayGain_ levels, or `acoustic fingerprints`_. - Get metadata from MusicBrainz_, Discogs_, and Beatport_. Or guess metadata using songs' filenames or their acoustic fingerprints. - `Transcode audio`_ to any format you like. - Check your library for `duplicate tracks and albums`_ or for `albums that are missing tracks`_. - Clean up crufty tags left behind by other, less-awesome tools. - Embed and extract album art from files' metadata. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. - Analyze music files' metadata from the command line. - Listen to your library with a music player that speaks the MPD_ protocol and works with a staggering variety of interfaces. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html .. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html .. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html .. _beatport: https://www.beatport.com .. _discogs: https://www.discogs.com/ .. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html .. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html .. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element .. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html .. _mpd: https://www.musicpd.org/ .. _musicbrainz: https://musicbrainz.org/ .. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/ .. _plugins: https://beets.readthedocs.org/page/plugins/ .. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html .. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html Install ------- You can install beets by typing ``pip install beets`` or directly from Github (see details here_). Beets has also been packaged in the `software repositories`_ of several distributions. Check out the `Getting Started`_ guide for more information. .. _getting started: https://beets.readthedocs.org/page/guides/main.html .. _here: https://beets.readthedocs.io/en/latest/faq.html#run-the-latest-source-version-of-beets .. _software repositories: https://repology.org/project/beets/versions Contribute ---------- Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at CONTRIBUTING.rst_. .. _contributing.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Mastodon for news and updates. .. _@b33ts: https://fosstodon.org/@beets .. _its web site: https://beets.io/ Contact ------- - Encountered a bug you'd like to report? Check out our `issue tracker`_! - If your issue hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. - If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others. - Need help/support, would like to start a discussion, have an idea for a new feature, or would just like to introduce yourself to the team? Check out `GitHub Discussions`_! .. _github discussions: https://github.com/beetbox/beets/discussions .. _issue tracker: https://github.com/beetbox/beets/issues .. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose Authors ------- Beets is by `Adrian Sampson`_ with a supporting cast of thousands. .. _adrian sampson: https://www.cs.cornell.edu/~asampson/ ================================================ FILE: README_kr.rst ================================================ .. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://app.codecov.io/github/beetbox/beets .. image:: https://travis-ci.org/beetbox/beets.svg?branch=master :target: https://travis-ci.org/beetbox/beets beets ===== Beets는 강박적인 음악을 듣는 사람들을 위한 미디어 라이브러리 관리 시스템이다. Beets의 목적은 음악들을 한번에 다 받는 것이다. 음악들을 카탈로그화 하고, 자동으로 메타 데이터를 개선한다. 그리고 음악에 접근하고 조작할 수 있는 도구들을 제공한다. 다음은 Beets의 brainy tag corrector가 한 일의 예시이다. :: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들에 대해 상상하는 모든 것을 할 수 있다. plugins_ 을 통해서 모든 것을 할 수 있는 것이다! - 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_, lyrics_, genres_, tempos_, ReplayGain_ levels, or `acoustic fingerprints`_. - MusicBrainz_, Discogs_,`Beatport`_로부터 메타데이터를 가져오거나, 노래 제목이나 음향 특징으로 메타데이터를 추측한다 - `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다. - 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다. - 남이 남기거나, 좋지 않은 도구로 남긴 잡다한 태그들을 지운다. - 파일의 메타데이터에서 앨범 아트를 삽입이나 추출한다. - 당신의 음악들을 `HTML5 Audio`_ 를 지원하는 어떤 브라우저든 재생할 수 있고, 웹 브라우저에 표시 할 수 있다. - 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다. - MPD_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다. 만약 Beets에 당신이 원하는게 아직 없다면, 당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다. .. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html .. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html .. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html .. _beatport: https://www.beatport.com .. _discogs: https://www.discogs.com/ .. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html .. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html .. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element .. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html .. _mpd: https://www.musicpd.org/ .. _musicbrainz: https://musicbrainz.org/ .. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/ .. _plugins: https://beets.readthedocs.org/page/plugins/ .. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html .. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html 설치 ------- 당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다. 그리고 `Getting Started`_ 가이드를 확인할 수 있다. .. _getting started: https://beets.readthedocs.org/page/guides/main.html 컨트리뷰션 ---------- 어떻게 도우려는지 알고싶다면 Hacking_ 위키페이지를 확인하라. 당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다. .. _for developers: https://beets.readthedocs.io/en/stable/dev/ .. _hacking: https://github.com/beetbox/beets/wiki/Hacking Read More --------- `its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다. 트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다. .. _@b33ts: https://twitter.com/b33ts/ .. _its web site: https://beets.io/ 저자들 ------- `Adrian Sampson`_ 와 많은 사람들의 지지를 받아 Beets를 만들었다. 돕고 싶다면 forum_.를 방문하면 된다. .. _adrian sampson: https://www.cs.cornell.edu/~asampson/ .. _forum: https://github.com/beetbox/beets/discussions/ ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We currently support only the latest release of beets. ## Reporting a Vulnerability To report a security vulnerability, please send email to [our Zulip team][z]. [z]: mailto:email.218c36e48d78cf125c0a6219a6c2a417.show-sender@streams.zulipchat.com ================================================ FILE: beets/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from sys import stderr import confuse from .util.deprecation import deprecate_imports __version__ = "2.7.1" __author__ = "Adrian Sampson " def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( __name__, {"art": "beetsplug._utils", "vfs": "beetsplug._utils"}, name, ) class IncludeLazyConfig(confuse.LazyConfig): """A version of Confuse's LazyConfig that also merges in data from YAML files specified in an `include` setting. """ def read(self, user: bool = True, defaults: bool = True) -> None: super().read(user, defaults) try: for view in self["include"].sequence(): self.set_file(view.as_filename()) except confuse.NotFoundError: pass except confuse.ConfigReadError as err: stderr.write(f"configuration `import` failed: {err.reason}") config = IncludeLazyConfig("beets", __name__) ================================================ FILE: beets/__main__.py ================================================ # This file is part of beets. # Copyright 2017, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The __main__ module lets you run the beets CLI interface by typing `python -m beets`. """ import sys from .ui import main if __name__ == "__main__": main(sys.argv[1:]) ================================================ FILE: beets/autotag/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Facilities for automatically determining files' correct metadata.""" from __future__ import annotations from importlib import import_module from typing import TYPE_CHECKING from beets import config, logging # Parts of external interface. from beets.util import unique_list from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch from .match import Proposal, Recommendation, tag_album, tag_item if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Album, Item, LibModel def __getattr__(name: str): if name == "current_metadata": deprecate_for_maintainers( f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'" ) return import_module("beets.util").get_most_common_tags return deprecate_imports( __name__, {"Distance": "beets.autotag.distance"}, name ) __all__ = [ "AlbumInfo", "AlbumMatch", "Proposal", "Recommendation", "TrackInfo", "TrackMatch", "apply_album_metadata", "apply_item_metadata", "apply_metadata", "tag_album", "tag_item", ] # Global logger. log = logging.getLogger("beets") # Metadata fields that are already hardcoded, or where the tag name changes. SPECIAL_FIELDS = { "album": ( "va", "releasegroup_id", "artist_id", "artists_ids", "album_id", "mediums", "tracks", "year", "month", "day", "artist", "artists", "artist_credit", "artists_credit", "artist_sort", "artists_sort", "data_url", ), "track": ( "track_alt", "artist_id", "artists_ids", "release_track_id", "medium", "index", "medium_index", "title", "artist_credit", "artists_credit", "artist_sort", "artists_sort", "artist", "artists", "track_id", "medium_total", "data_url", "length", ), } # Additional utilities for the main interface. def _apply_metadata( info: AlbumInfo | TrackInfo, db_obj: Album | Item, nullable_fields: Sequence[str] = [], ): """Set the db_obj's metadata to match the info.""" special_fields = SPECIAL_FIELDS[ "album" if isinstance(info, AlbumInfo) else "track" ] for field, value in info.items(): # We only overwrite fields that are not already hardcoded. if field in special_fields: continue # Don't overwrite fields with empty values unless the # field is explicitly allowed to be overwritten. if value is None and field not in nullable_fields: continue db_obj[field] = value def correct_list_fields(m: LibModel) -> None: """Synchronise single and list values for the list fields that we use. That is, ensure the same value in the single field and the first element in the list. For context, the value we set as, say, ``mb_artistid`` is simply ignored: Under the current :class:`MediaFile` implementation, fields ``albumtype``, ``mb_artistid`` and ``mb_albumartistid`` are mapped to the first element of ``albumtypes``, ``mb_artistids`` and ``mb_albumartistids`` respectively. This means setting ``mb_artistid`` has no effect. However, beets functionality still assumes that ``mb_artistid`` is independent and stores its value in the database. If ``mb_artistid`` != ``mb_artistids[0]``, ``beet write`` command thinks that ``mb_artistid`` is modified and tries to update the field in the file. Of course nothing happens, so the same diff is shown every time the command is run. We can avoid this issue by ensuring that ``mb_artistid`` has the same value as ``mb_artistids[0]``, and that's what this function does. Note: :class:`Album` model does not have ``mb_artistids`` and ``mb_albumartistids`` fields therefore we need to check for their presence. """ def ensure_first_value(single_field: str, list_field: str) -> None: """Ensure the first ``list_field`` item is equal to ``single_field``.""" single_val, list_val = getattr(m, single_field), getattr(m, list_field) if single_val: setattr(m, list_field, unique_list([single_val, *list_val])) elif list_val: setattr(m, single_field, list_val[0]) ensure_first_value("albumtype", "albumtypes") if hasattr(m, "mb_artistids"): ensure_first_value("mb_artistid", "mb_artistids") if hasattr(m, "mb_albumartistids"): ensure_first_value("mb_albumartistid", "mb_albumartistids") def apply_item_metadata(item: Item, track_info: TrackInfo): """Set an item's metadata from its matched TrackInfo object.""" item.artist = track_info.artist item.artists = track_info.artists item.artist_sort = track_info.artist_sort item.artists_sort = track_info.artists_sort item.artist_credit = track_info.artist_credit item.artists_credit = track_info.artists_credit item.title = track_info.title item.mb_trackid = track_info.track_id item.mb_releasetrackid = track_info.release_track_id if track_info.artist_id: item.mb_artistid = track_info.artist_id if track_info.artists_ids: item.mb_artistids = track_info.artists_ids _apply_metadata(track_info, item) correct_list_fields(item) # At the moment, the other metadata is left intact (including album # and track number). Perhaps these should be emptied? def apply_album_metadata(album_info: AlbumInfo, album: Album): """Set the album's metadata to match the AlbumInfo object.""" _apply_metadata(album_info, album) correct_list_fields(album) def apply_metadata( album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]] ): """Set items metadata to match corresponding tagged info.""" for item, track_info in item_info_pairs: # Artist or artist credit. if config["artist_credit"]: item.artist = ( track_info.artist_credit or track_info.artist or album_info.artist_credit or album_info.artist ) item.artists = ( track_info.artists_credit or track_info.artists or album_info.artists_credit or album_info.artists ) item.albumartist = album_info.artist_credit or album_info.artist item.albumartists = album_info.artists_credit or album_info.artists else: item.artist = track_info.artist or album_info.artist item.artists = track_info.artists or album_info.artists item.albumartist = album_info.artist item.albumartists = album_info.artists # Album. item.album = album_info.album # Artist sort and credit names. item.artist_sort = track_info.artist_sort or album_info.artist_sort item.artists_sort = track_info.artists_sort or album_info.artists_sort item.artist_credit = ( track_info.artist_credit or album_info.artist_credit ) item.artists_credit = ( track_info.artists_credit or album_info.artists_credit ) item.albumartist_sort = album_info.artist_sort item.albumartists_sort = album_info.artists_sort item.albumartist_credit = album_info.artist_credit item.albumartists_credit = album_info.artists_credit # Release date. for prefix in "", "original_": if config["original_date"] and not prefix: # Ignore specific release date. continue for suffix in "year", "month", "day": key = f"{prefix}{suffix}" value = getattr(album_info, key) or 0 # If we don't even have a year, apply nothing. if suffix == "year" and not value: break # Otherwise, set the fetched value (or 0 for the month # and day if not available). item[key] = value # If we're using original release date for both fields, # also set item.year = info.original_year, etc. if config["original_date"]: item[suffix] = value # Title. item.title = track_info.title if config["per_disc_numbering"]: # We want to let the track number be zero, but if the medium index # is not provided we need to fall back to the overall index. if track_info.medium_index is not None: item.track = track_info.medium_index else: item.track = track_info.index item.tracktotal = track_info.medium_total or len(album_info.tracks) else: item.track = track_info.index item.tracktotal = len(album_info.tracks) # Disc and disc count. item.disc = track_info.medium item.disctotal = album_info.mediums # MusicBrainz IDs. item.mb_trackid = track_info.track_id item.mb_releasetrackid = track_info.release_track_id item.mb_albumid = album_info.album_id if track_info.artist_id: item.mb_artistid = track_info.artist_id else: item.mb_artistid = album_info.artist_id if track_info.artists_ids: item.mb_artistids = track_info.artists_ids else: item.mb_artistids = album_info.artists_ids item.mb_albumartistid = album_info.artist_id item.mb_albumartistids = album_info.artists_ids item.mb_releasegroupid = album_info.releasegroup_id # Compilation flag. item.comp = album_info.va # Track alt. item.track_alt = track_info.track_alt _apply_metadata( album_info, item, nullable_fields=config["overwrite_null"]["album"].as_str_seq(), ) _apply_metadata( track_info, item, nullable_fields=config["overwrite_null"]["track"].as_str_seq(), ) correct_list_fields(item) ================================================ FILE: beets/autotag/distance.py ================================================ from __future__ import annotations import datetime import re from functools import cache, total_ordering from typing import TYPE_CHECKING, Any from jellyfish import levenshtein_distance from unidecode import unidecode from beets import config, metadata_plugins from beets.util import as_string, cached_classproperty, get_most_common_tags if TYPE_CHECKING: from collections.abc import Iterator, Sequence from beets.library import Item from .hooks import AlbumInfo, TrackInfo # Candidate distance scoring. # Artist signals that indicate "various artists". These are used at the # album level to determine whether a given release is likely a VA # release and also on the track level to to remove the penalty for # differing artists. VA_ARTISTS = ("", "various artists", "various", "va", "unknown") # Parameters for string distance function. # Words that can be moved to the end of a string using a comma. SD_END_WORDS = ["the", "a", "an"] # Reduced weights for certain portions of the string. SD_PATTERNS = [ (r"^the ", 0.1), (r"[\[\(]?(ep|single)[\]\)]?", 0.0), (r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1), (r"\(.*?\)", 0.3), (r"\[.*?\]", 0.3), (r"(, )?(pt\.|part) .+", 0.2), ] # Replacements to use before testing distance. SD_REPLACE = [ (r"&", "and"), ] def _string_dist_basic(str1: str, str2: str) -> float: """Basic edit distance between two strings, ignoring non-alphanumeric characters and case. Comparisons are based on a transliteration/lowering to ASCII characters. Normalized by string length. """ assert isinstance(str1, str) assert isinstance(str2, str) str1 = as_string(unidecode(str1)) str2 = as_string(unidecode(str2)) str1 = re.sub(r"[^a-z0-9]", "", str1.lower()) str2 = re.sub(r"[^a-z0-9]", "", str2.lower()) if not str1 and not str2: return 0.0 return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2))) def string_dist(str1: str | None, str2: str | None) -> float: """Gives an "intuitive" edit distance between two strings. This is an edit distance, normalized by the string length, with a number of tweaks that reflect intuition about text. """ if str1 is None and str2 is None: return 0.0 if str1 is None or str2 is None: return 1.0 str1 = str1.lower() str2 = str2.lower() # Don't penalize strings that move certain words to the end. For # example, "the something" should be considered equal to # "something, the". for word in SD_END_WORDS: if str1.endswith(f", {word}"): str1 = f"{word} {str1[: -len(word) - 2]}" if str2.endswith(f", {word}"): str2 = f"{word} {str2[: -len(word) - 2]}" # Perform a couple of basic normalizing substitutions. for pat, repl in SD_REPLACE: str1 = re.sub(pat, repl, str1) str2 = re.sub(pat, repl, str2) # Change the weight for certain string portions matched by a set # of regular expressions. We gradually change the strings and build # up penalties associated with parts of the string that were # deleted. base_dist = _string_dist_basic(str1, str2) penalty = 0.0 for pat, weight in SD_PATTERNS: # Get strings that drop the pattern. case_str1 = re.sub(pat, "", str1) case_str2 = re.sub(pat, "", str2) if case_str1 != str1 or case_str2 != str2: # If the pattern was present (i.e., it is deleted in the # the current case), recalculate the distances for the # modified strings. case_dist = _string_dist_basic(case_str1, case_str2) case_delta = max(0.0, base_dist - case_dist) if case_delta == 0.0: continue # Shift our baseline strings down (to avoid rematching the # same part of the string) and add a scaled distance # amount to the penalties. str1 = case_str1 str2 = case_str2 base_dist = case_dist penalty += weight * case_delta return base_dist + penalty @total_ordering class Distance: """Keeps track of multiple distance penalties. Provides a single weighted distance for all penalties as well as a weighted distance for each individual penalty. """ def __init__(self) -> None: self._penalties: dict[str, list[float]] = {} self.tracks: dict[TrackInfo, Distance] = {} @cached_classproperty def _weights(cls) -> dict[str, float]: """A dictionary from keys to floating-point weights.""" weights_view = config["match"]["distance_weights"] weights = {} for key in weights_view.keys(): weights[key] = weights_view[key].as_number() return weights # Access the components and their aggregates. @property def distance(self) -> float: """Return a weighted and normalized distance across all penalties. """ dist_max = self.max_distance if dist_max: return self.raw_distance / self.max_distance return 0.0 @property def max_distance(self) -> float: """Return the maximum distance penalty (normalization factor).""" dist_max = 0.0 for key, penalty in self._penalties.items(): dist_max += len(penalty) * self._weights[key] return dist_max @property def raw_distance(self) -> float: """Return the raw (denormalized) distance.""" dist_raw = 0.0 for key, penalty in self._penalties.items(): dist_raw += sum(penalty) * self._weights[key] return dist_raw def items(self) -> list[tuple[str, float]]: """Return a list of (key, dist) pairs, with `dist` being the weighted distance, sorted from highest to lowest. Does not include penalties with a zero value. """ list_ = [] for key in self._penalties: dist = self[key] if dist: list_.append((key, dist)) # Convert distance into a negative float we can sort items in # ascending order (for keys, when the penalty is equal) and # still get the items with the biggest distance first. return sorted( list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0]) ) def __hash__(self) -> int: return id(self) def __eq__(self, other) -> bool: return self.distance == other # Behave like a float. def __lt__(self, other) -> bool: return self.distance < other def __float__(self) -> float: return self.distance def __sub__(self, other) -> float: return self.distance - other def __rsub__(self, other) -> float: return other - self.distance def __str__(self) -> str: return f"{self.distance:.2f}" # Behave like a dict. def __getitem__(self, key) -> float: """Returns the weighted distance for a named penalty.""" dist = sum(self._penalties[key]) * self._weights[key] dist_max = self.max_distance if dist_max: return dist / dist_max return 0.0 def __iter__(self) -> Iterator[tuple[str, float]]: return iter(self.items()) def __len__(self) -> int: return len(self.items()) def keys(self) -> list[str]: return [key for key, _ in self.items()] def update(self, dist: Distance): """Adds all the distance penalties from `dist`.""" if not isinstance(dist, Distance): raise ValueError( f"`dist` must be a Distance object, not {type(dist)}" ) for key, penalties in dist._penalties.items(): self._penalties.setdefault(key, []).extend(penalties) # Adding components. def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool: """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. """ if isinstance(value1, re.Pattern): return bool(value1.match(value2)) return value1 == value2 def add(self, key: str, dist: float): """Adds a distance penalty. `key` must correspond with a configured weight setting. `dist` must be a float between 0.0 and 1.0, and will be added to any existing distance penalties for the same key. """ if not 0.0 <= dist <= 1.0: raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}") self._penalties.setdefault(key, []).append(dist) def add_equality( self, key: str, value: Any, options: list[Any] | tuple[Any, ...] | Any, ): """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular expression, it will be considered equal if it matches against `value`. """ if not isinstance(options, (list, tuple)): options = [options] for opt in options: if self._eq(opt, value): dist = 0.0 break else: dist = 1.0 self.add(key, dist) def add_expr(self, key: str, expr: bool): """Adds a distance penalty of 1.0 if `expr` evaluates to True, or 0.0. """ if expr: self.add(key, 1.0) else: self.add(key, 0.0) def add_number(self, key: str, number1: int, number2: int): """Adds a distance penalty of 1.0 for each number of difference between `number1` and `number2`, or 0.0 when there is no difference. Use this when there is no upper limit on the difference between the two numbers. """ diff = abs(number1 - number2) if diff: for i in range(diff): self.add(key, 1.0) else: self.add(key, 0.0) def add_priority( self, key: str, value: Any, options: list[Any] | tuple[Any, ...] | Any, ): """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 for the first option, or 1.0 if there is no matching option. If an option is a compiled regular expression, it will be considered equal if it matches against `value`. """ if not isinstance(options, (list, tuple)): options = [options] unit = 1.0 / (len(options) or 1) for i, opt in enumerate(options): if self._eq(opt, value): dist = i * unit break else: dist = 1.0 self.add(key, dist) def add_ratio( self, key: str, number1: int | float, number2: int | float, ): """Adds a distance penalty for `number1` as a ratio of `number2`. `number1` is bound at 0 and `number2`. """ number = float(max(min(number1, number2), 0)) if number2: dist = number / number2 else: dist = 0.0 self.add(key, dist) def add_string(self, key: str, str1: str | None, str2: str | None): """Adds a distance penalty based on the edit distance between `str1` and `str2`. """ dist = string_dist(str1, str2) self.add(key, dist) def add_data_source(self, before: str | None, after: str | None) -> None: if before != after and ( before or len(metadata_plugins.find_metadata_source_plugins()) > 1 ): self.add("data_source", metadata_plugins.get_penalty(after)) @cache def get_track_length_grace() -> float: """Get cached grace period for track length matching.""" return config["match"]["track_length_grace"].as_number() @cache def get_track_length_max() -> float: """Get cached maximum track length for track length matching.""" return config["match"]["track_length_max"].as_number() def track_index_changed(item: Item, track_info: TrackInfo) -> bool: """Returns True if the item and track info index is different. Tolerates per disc and per release numbering. """ return item.track not in (track_info.medium_index, track_info.index) def track_distance( item: Item, track_info: TrackInfo, incl_artist: bool = False, ) -> Distance: """Determines the significance of a track metadata change. Returns a Distance object. `incl_artist` indicates that a distance component should be included for the track artist (i.e., for various-artist releases). ``track_length_grace`` and ``track_length_max`` configuration options are cached because this function is called many times during the matching process and their access comes with a performance overhead. """ dist = Distance() # Length. if info_length := track_info.length: diff = abs(item.length - info_length) - get_track_length_grace() dist.add_ratio("track_length", diff, get_track_length_max()) # Title. dist.add_string("track_title", item.title, track_info.title) # Artist. Only check if there is actually an artist in the track data. if ( incl_artist and track_info.artist and item.artist.lower() not in VA_ARTISTS ): dist.add_string("track_artist", item.artist, track_info.artist) # Track index. if track_info.index and item.track: dist.add_expr("track_index", track_index_changed(item, track_info)) # Track ID. if item.mb_trackid: dist.add_expr("track_id", item.mb_trackid != track_info.track_id) # Penalize mismatching disc numbers. if track_info.medium and item.disc: dist.add_expr("medium", item.disc != track_info.medium) dist.add_data_source(item.get("data_source"), track_info.data_source) return dist def distance( items: Sequence[Item], album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]], ) -> Distance: """Determines how "significant" an album metadata change would be. Returns a Distance object. `album_info` is an AlbumInfo object reflecting the album to be compared. `items` is a sequence of all Item objects that will be matched (order is not important). `mapping` is a dictionary mapping Items to TrackInfo objects; the keys are a subset of `items` and the values are a subset of `album_info.tracks`. """ likelies, _ = get_most_common_tags(items) dist = Distance() # Artist, if not various. if not album_info.va: dist.add_string("artist", likelies["artist"], album_info.artist) # Album. dist.add_string("album", likelies["album"], album_info.album) preferred_config = config["match"]["preferred"] # Current or preferred media. if album_info.media: # Preferred media options. media_patterns: Sequence[str] = preferred_config["media"].as_str_seq() options = [ re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns ] if options: dist.add_priority("media", album_info.media, options) # Current media. elif likelies["media"]: dist.add_equality("media", album_info.media, likelies["media"]) # Mediums. if likelies["disctotal"] and album_info.mediums: dist.add_number("mediums", likelies["disctotal"], album_info.mediums) # Prefer earliest release. if album_info.year and preferred_config["original_year"]: # Assume 1889 (earliest first gramophone discs) if we don't know the # original year. original = album_info.original_year or 1889 diff = abs(album_info.year - original) diff_max = abs(datetime.date.today().year - original) dist.add_ratio("year", diff, diff_max) # Year. elif likelies["year"] and album_info.year: if likelies["year"] in (album_info.year, album_info.original_year): # No penalty for matching release or original year. dist.add("year", 0.0) elif album_info.original_year: # Prefer matchest closest to the release year. diff = abs(likelies["year"] - album_info.year) diff_max = abs( datetime.date.today().year - album_info.original_year ) dist.add_ratio("year", diff, diff_max) else: # Full penalty when there is no original year. dist.add("year", 1.0) # Preferred countries. country_patterns: Sequence[str] = preferred_config["countries"].as_str_seq() options = [re.compile(pat, re.I) for pat in country_patterns] if album_info.country and options: dist.add_priority("country", album_info.country, options) # Country. elif likelies["country"] and album_info.country: dist.add_string("country", likelies["country"], album_info.country) # Label. if likelies["label"] and album_info.label: dist.add_string("label", likelies["label"], album_info.label) # Catalog number. if likelies["catalognum"] and album_info.catalognum: dist.add_string( "catalognum", likelies["catalognum"], album_info.catalognum ) # Disambiguation. if likelies["albumdisambig"] and album_info.albumdisambig: dist.add_string( "albumdisambig", likelies["albumdisambig"], album_info.albumdisambig ) # Album ID. if likelies["mb_albumid"]: dist.add_equality( "album_id", likelies["mb_albumid"], album_info.album_id ) # Tracks. dist.tracks = {} for item, track in item_info_pairs: dist.tracks[track] = track_distance(item, track, album_info.va) dist.add("tracks", dist.tracks[track].distance) # Missing tracks. for _ in range(len(album_info.tracks) - len(item_info_pairs)): dist.add("missing_tracks", 1.0) # Unmatched tracks. for _ in range(len(items) - len(item_info_pairs)): dist.add("unmatched_tracks", 1.0) dist.add_data_source(likelies["data_source"], album_info.data_source) return dist ================================================ FILE: beets/autotag/hooks.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Glue between metadata sources and the matching logic.""" from __future__ import annotations from copy import deepcopy from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import Self from beets import plugins from beets.util import cached_classproperty from beets.util.deprecation import deprecate_for_maintainers if TYPE_CHECKING: from beets.library import Item from .distance import Distance V = TypeVar("V") # Classes used to represent candidate options. class AttrDict(dict[str, V]): """Mapping enabling attribute-style access to stored metadata values.""" def copy(self) -> Self: return deepcopy(self) def __getattr__(self, attr: str) -> V: if attr in self: return self[attr] raise AttributeError( f"'{self.__class__.__name__}' object has no attribute '{attr}'" ) def __setattr__(self, key: str, value: V) -> None: self.__setitem__(key, value) def __hash__(self) -> int: # type: ignore[override] return id(self) class Info(AttrDict[Any]): """Container for metadata about a musical entity.""" Identifier = tuple[str | None, str | None] @property def id(self) -> str | None: """Return the provider-specific identifier for this metadata object.""" raise NotImplementedError @property def identifier(self) -> Identifier: """Return a cross-provider key in ``(data_source, id)`` form.""" return (self.data_source, self.id) @cached_property def name(self) -> str: raise NotImplementedError def __init__( self, album: str | None = None, artist_credit: str | None = None, artist_id: str | None = None, artist: str | None = None, artists_credit: list[str] | None = None, artists_ids: list[str] | None = None, artists: list[str] | None = None, artist_sort: str | None = None, artists_sort: list[str] | None = None, data_source: str | None = None, data_url: str | None = None, genre: str | None = None, genres: list[str] | None = None, media: str | None = None, **kwargs, ) -> None: if genre is not None: deprecate_for_maintainers( "The 'genre' parameter", "'genres' (list)", stacklevel=3 ) if not genres: try: sep = next(s for s in ["; ", ", ", " / "] if s in genre) except StopIteration: genres = [genre] else: genres = list(map(str.strip, genre.split(sep))) self.album = album self.artist = artist self.artist_credit = artist_credit self.artist_id = artist_id self.artists = artists self.artists_credit = artists_credit self.artists_ids = artists_ids self.artist_sort = artist_sort self.artists_sort = artists_sort self.data_source = data_source self.data_url = data_url self.genre = None self.genres = genres self.media = media self.update(kwargs) class AlbumInfo(Info): """Metadata snapshot representing a single album candidate. Aggregates track entries and album-wide context gathered from an external provider. Used during matching to evaluate similarity against a group of user items, and later to drive tagging decisions once selected. """ @property def id(self) -> str | None: return self.album_id @cached_property def name(self) -> str: return self.album or "" def __init__( self, tracks: list[TrackInfo], *, album_id: str | None = None, albumdisambig: str | None = None, albumstatus: str | None = None, albumtype: str | None = None, albumtypes: list[str] | None = None, asin: str | None = None, barcode: str | None = None, catalognum: str | None = None, country: str | None = None, day: int | None = None, discogs_albumid: str | None = None, discogs_artistid: str | None = None, discogs_labelid: str | None = None, label: str | None = None, language: str | None = None, mediums: int | None = None, month: int | None = None, original_day: int | None = None, original_month: int | None = None, original_year: int | None = None, release_group_title: str | None = None, releasegroup_id: str | None = None, releasegroupdisambig: str | None = None, script: str | None = None, style: str | None = None, va: bool = False, year: int | None = None, **kwargs, ) -> None: self.tracks = tracks self.album_id = album_id self.albumdisambig = albumdisambig self.albumstatus = albumstatus self.albumtype = albumtype self.albumtypes = albumtypes self.asin = asin self.barcode = barcode self.catalognum = catalognum self.country = country self.day = day self.discogs_albumid = discogs_albumid self.discogs_artistid = discogs_artistid self.discogs_labelid = discogs_labelid self.label = label self.language = language self.mediums = mediums self.month = month self.original_day = original_day self.original_month = original_month self.original_year = original_year self.release_group_title = release_group_title self.releasegroup_id = releasegroup_id self.releasegroupdisambig = releasegroupdisambig self.script = script self.style = style self.va = va self.year = year super().__init__(**kwargs) class TrackInfo(Info): """Metadata snapshot for a single track candidate. Captures identifying details and creative credits used to compare against a user's item. Instances often originate within an AlbumInfo but may also stand alone for singleton matching. """ @property def id(self) -> str | None: return self.track_id @cached_property def name(self) -> str: return self.title or "" def __init__( self, *, arranger: str | None = None, bpm: str | None = None, composer: str | None = None, composer_sort: str | None = None, disctitle: str | None = None, index: int | None = None, initial_key: str | None = None, length: float | None = None, lyricist: str | None = None, mb_workid: str | None = None, medium: int | None = None, medium_index: int | None = None, medium_total: int | None = None, release_track_id: str | None = None, title: str | None = None, track_alt: str | None = None, track_id: str | None = None, work: str | None = None, work_disambig: str | None = None, **kwargs, ) -> None: self.arranger = arranger self.bpm = bpm self.composer = composer self.composer_sort = composer_sort self.disctitle = disctitle self.index = index self.initial_key = initial_key self.length = length self.lyricist = lyricist self.mb_workid = mb_workid self.medium = medium self.medium_index = medium_index self.medium_total = medium_total self.release_track_id = release_track_id self.title = title self.track_alt = track_alt self.track_id = track_id self.work = work self.work_disambig = work_disambig super().__init__(**kwargs) # Structures that compose all the information for a candidate match. @dataclass class Match: distance: Distance info: Info @cached_classproperty def type(cls) -> str: return cls.__name__.removesuffix("Match") # type: ignore[attr-defined] @dataclass class AlbumMatch(Match): info: AlbumInfo mapping: dict[Item, TrackInfo] extra_items: list[Item] extra_tracks: list[TrackInfo] def __post_init__(self) -> None: """Notify listeners when an album candidate has been matched.""" plugins.send("album_matched", match=self) @property def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]: return list(self.mapping.items()) @property def items(self) -> list[Item]: return [i for i, _ in self.item_info_pairs] @dataclass class TrackMatch(Match): info: TrackInfo ================================================ FILE: beets/autotag/match.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Matches existing metadata with canonical information to identify releases and tracks. """ from __future__ import annotations from enum import IntEnum from typing import TYPE_CHECKING, NamedTuple, TypeVar import lap import numpy as np from beets import config, logging, metadata_plugins from beets.autotag import AlbumMatch, TrackMatch, hooks from beets.util import get_most_common_tags from .distance import VA_ARTISTS, distance, track_distance from .hooks import Info if TYPE_CHECKING: from collections.abc import Iterable, Sequence from beets.autotag import AlbumInfo, TrackInfo from beets.library import Item AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch) Candidates = dict[Info.Identifier, AnyMatch] # Global logger. log = logging.getLogger("beets") # Recommendation enumeration. class Recommendation(IntEnum): """Indicates a qualitative suggestion to the user about what should be done with a given match. """ none = 0 low = 1 medium = 2 strong = 3 # A structure for holding a set of possible matches to choose between. This # consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo # objects) and a recommendation value. class Proposal(NamedTuple): candidates: Sequence[AlbumMatch | TrackMatch] recommendation: Recommendation # Primary matching functionality. def assign_items( items: Sequence[Item], tracks: Sequence[TrackInfo], ) -> tuple[list[tuple[Item, TrackInfo]], list[Item], list[TrackInfo]]: """Given a list of Items and a list of TrackInfo objects, find the best mapping between them. Returns a mapping from Items to TrackInfo objects, a set of extra Items, and a set of extra TrackInfo objects. These "extra" objects occur when there is an unequal number of objects of the two types. """ log.debug("Computing track assignment...") # Construct the cost matrix. costs = [[float(track_distance(i, t)) for t in tracks] for i in items] # Assign items to tracks _, _, assigned_item_idxs = lap.lapjv(np.array(costs), extend_cost=True) log.debug("...done.") # Each item in `assigned_item_idxs` list corresponds to a track in the # `tracks` list. Each value is either an index into the assigned item in # `items` list, or -1 if that track has no match. mapping = { items[iidx]: t for iidx, t in zip(assigned_item_idxs, tracks) if iidx != -1 } extra_items = list(set(items) - mapping.keys()) extra_items.sort(key=lambda i: (i.disc, i.track, i.title)) extra_tracks = list(set(tracks) - set(mapping.values())) extra_tracks.sort(key=lambda t: (t.index, t.title)) return list(mapping.items()), extra_items, extra_tracks def match_by_id(album_id: str | None, consensus: bool) -> Iterable[AlbumInfo]: """Return album candidates for the given album id. Make sure that the ID is present and that there is consensus on it among the items being tagged. """ if not album_id: log.debug("No album ID found.") elif not consensus: log.debug("No album ID consensus.") else: log.debug("Searching for discovered album ID: {}", album_id) return metadata_plugins.albums_for_ids([album_id]) return () def _recommendation( results: Sequence[AlbumMatch | TrackMatch], ) -> Recommendation: """Given a sorted list of AlbumMatch or TrackMatch objects, return a recommendation based on the results' distances. If the recommendation is higher than the configured maximum for an applied penalty, the recommendation will be downgraded to the configured maximum for that penalty. """ if not results: # No candidates: no recommendation. return Recommendation.none # Basic distance thresholding. min_dist = results[0].distance if min_dist < config["match"]["strong_rec_thresh"].as_number(): # Strong recommendation level. rec = Recommendation.strong elif min_dist <= config["match"]["medium_rec_thresh"].as_number(): # Medium recommendation level. rec = Recommendation.medium elif len(results) == 1: # Only a single candidate. rec = Recommendation.low elif ( results[1].distance - min_dist >= config["match"]["rec_gap_thresh"].as_number() ): # Gap between first two candidates is large. rec = Recommendation.low else: # No conclusion. Return immediately. Can't be downgraded any further. return Recommendation.none # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. keys = set(min_dist.keys()) if isinstance(results[0], hooks.AlbumMatch): for track_dist in min_dist.tracks.values(): keys.update(list(track_dist.keys())) max_rec_view = config["match"]["max_rec"] for key in keys: if key in list(max_rec_view.keys()): max_rec = max_rec_view[key].as_choice( { "strong": Recommendation.strong, "medium": Recommendation.medium, "low": Recommendation.low, "none": Recommendation.none, } ) rec = min(rec, max_rec) return rec def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]: """Sort candidates by distance.""" return sorted(candidates, key=lambda match: match.distance) def _add_candidate( items: Sequence[Item], results: Candidates[AlbumMatch], info: AlbumInfo, ): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ log.debug( "Candidate: {0.artist} - {0.album} ({0.album_id}) from {0.data_source}", info, ) # Discard albums with zero tracks. if not info.tracks: log.debug("No tracks.") return # Prevent duplicates. if info.album_id and info.identifier in results: log.debug("Duplicate.") return # Discard matches without required tags. required_tags: Sequence[str] = config["match"]["required"].as_str_seq() for req_tag in required_tags: if getattr(info, req_tag) is None: log.debug("Ignored. Missing required tag: {}", req_tag) return # Find mapping between the items and the track info. item_info_pairs, extra_items, extra_tracks = assign_items( items, info.tracks ) # Get the change distance. dist = distance(items, info, item_info_pairs) # Skip matches with ignored penalties. penalties = [key for key, _ in dist] ignored_tags: Sequence[str] = config["match"]["ignored"].as_str_seq() for penalty in ignored_tags: if penalty in penalties: log.debug("Ignored. Penalty: {}", penalty) return log.debug("Success. Distance: {}", dist) results[info.identifier] = hooks.AlbumMatch( dist, info, dict(item_info_pairs), extra_items, extra_tracks ) def tag_album( items, search_artist: str | None = None, search_name: str | None = None, search_ids: list[str] = [], ) -> tuple[str, str, Proposal]: """Return a tuple of the current artist name, the current album name, and a `Proposal` containing `AlbumMatch` candidates. The artist and album are the most common values of these fields among `items`. The `AlbumMatch` objects are generated by searching the metadata backends. By default, the metadata of the items is used for the search. This can be customized by setting the parameters. `search_ids` is a list of metadata backend IDs: if specified, it will restrict the candidates to those IDs, ignoring `search_artist` and `search album`. The `mapping` field of the album has the matched `items` as keys. The recommendation is calculated from the match quality of the candidates. """ # Get current metadata. likelies, consensus = get_most_common_tags(items) cur_artist: str = likelies["artist"] cur_album: str = likelies["album"] log.debug("Tagging {} - {}", cur_artist, cur_album) # The output result, keys are (data_source, album_id) pairs, values are # AlbumMatch objects. candidates: Candidates[AlbumMatch] = {} # Search by explicit ID. if search_ids: log.debug("Searching for album IDs: {}", ", ".join(search_ids)) for _info in metadata_plugins.albums_for_ids(search_ids): _add_candidate(items, candidates, _info) # Use existing metadata or text search. else: # Try search based on current ID. for info in match_by_id( likelies["mb_albumid"], consensus["mb_albumid"] ): _add_candidate(items, candidates, info) rec = _recommendation(list(candidates.values())) log.debug("Album ID match recommendation is {}", rec) if candidates and not config["import"]["timid"]: # If we have a very good MBID match, return immediately. # Otherwise, this match will compete against metadata-based # matches. if rec == Recommendation.strong: log.debug("ID match.") return ( cur_artist, cur_album, Proposal(list(candidates.values()), rec), ) # Search terms. if not (search_artist and search_name): # No explicit search terms -- use current metadata. search_artist, search_name = cur_artist, cur_album log.debug("Search terms: {} - {}", search_artist, search_name) # Is this album likely to be a "various artist" release? va_likely = ( (not consensus["artist"]) or (search_artist.lower() in VA_ARTISTS) or any(item.comp for item in items) ) log.debug("Album might be VA: {}", va_likely) # Get the results from the data sources. for matched_candidate in metadata_plugins.candidates( items, search_artist, search_name, va_likely ): _add_candidate(items, candidates, matched_candidate) log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. candidates_sorted = _sort_candidates(candidates.values()) rec = _recommendation(candidates_sorted) return cur_artist, cur_album, Proposal(candidates_sorted, rec) def tag_item( item, search_artist: str | None = None, search_name: str | None = None, search_ids: list[str] | None = None, ) -> Proposal: """Find metadata for a single track. Return a `Proposal` consisting of `TrackMatch` objects. `search_artist` and `search_title` may be used to override the item metadata in the search query. `search_ids` may be used for restricting the search to a list of metadata backend IDs. """ # Holds candidates found so far: keys are (data_source, track_id) pairs, # values TrackMatch objects candidates: Candidates[TrackMatch] = {} rec: Recommendation | None = None # First, try matching by the external source ID. trackids = search_ids or [t for t in [item.mb_trackid] if t] if trackids: log.debug("Searching for track IDs: {}", ", ".join(trackids)) for info in metadata_plugins.tracks_for_ids(trackids): dist = track_distance(item, info, incl_artist=True) candidates[info.identifier] = hooks.TrackMatch(dist, info) # If this is a good match, then don't keep searching. rec = _recommendation(_sort_candidates(candidates.values())) if rec == Recommendation.strong and not config["import"]["timid"]: log.debug("Track ID match.") return Proposal(_sort_candidates(candidates.values()), rec) # If we're searching by ID, don't proceed. if search_ids: if candidates: assert rec is not None return Proposal(_sort_candidates(candidates.values()), rec) else: return Proposal([], Recommendation.none) # Search terms. search_artist = search_artist or item.artist search_name = search_name or item.title log.debug("Item search terms: {} - {}", search_artist, search_name) # Get and evaluate candidate metadata. for track_info in metadata_plugins.item_candidates( item, search_artist, search_name ): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.identifier] = hooks.TrackMatch(dist, track_info) # Sort by distance and return with recommendation. log.debug("Found {} candidates.", len(candidates)) candidates_sorted = _sort_candidates(candidates.values()) rec = _recommendation(candidates_sorted) return Proposal(candidates_sorted, rec) ================================================ FILE: beets/config_default.yaml ================================================ # --------------- Main --------------- library: library.db directory: ~/Music statefile: state.pickle # --------------- Plugins --------------- plugins: [musicbrainz] pluginpath: [] raise_on_error: no # --------------- Import --------------- clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] ignore_hidden: yes import: # common options write: yes copy: yes move: no timid: no quiet: no log: # other options default_action: apply languages: [] quiet_fallback: skip none_rec_action: ask # rare options link: no hardlink: no reflink: no delete: no resume: ask incremental: no incremental_skip_later: no from_scratch: no autotag: yes singletons: no detail: no flat: no group_albums: no pretend: false search_ids: [] duplicate_keys: album: albumartist album item: artist title duplicate_action: ask duplicate_verbose_prompt: no bell: no set_fields: {} ignored_alias_types: [] singleton_album_disambig: yes # --------------- Paths --------------- path_sep_replace: _ drive_sep_replace: _ asciify_paths: false art_filename: cover max_filename_length: 0 replace: # Replace bad characters with _ # prohibited in many filesystem paths '[<>:\?\*\|]': _ # double quotation mark " '\"': _ # path separators: \ or / '[\\/]': _ # starting and closing periods '^\.': _ '\.$': _ # control characters '[\x00-\x1f]': _ # dash at the start of a filename (causes command line ambiguity) '^-': _ # Replace bad characters with nothing # starting and closing whitespace '\s+$': '' '^\s+': '' aunique: keys: albumartist album disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig bracket: '[]' sunique: keys: artist title disambiguators: year trackdisambig bracket: '[]' # --------------- Tagging --------------- per_disc_numbering: no original_date: no artist_credit: no id3v23: no va_name: "Various Artists" paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title # --------------- Performance --------------- threaded: yes timeout: 5.0 # --------------- UI --------------- verbose: 0 terminal_encoding: ui: terminal_width: 80 length_diff_thresh: 10.0 color: yes colors: text_success: ['bold', 'green'] text_warning: ['bold', 'yellow'] text_error: ['bold', 'red'] text_highlight: ['bold', 'red'] text_highlight_minor: ['white'] action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New Colors text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] import: indentation: match_header: 2 match_details: 2 match_tracklist: 5 layout: column # --------------- Search --------------- format_item: $artist - $album - $title format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ sort_case_insensitive: yes # --------------- Autotagger --------------- overwrite_null: album: [] track: [] match: strong_rec_thresh: 0.04 medium_rec_thresh: 0.25 rec_gap_thresh: 0.25 max_rec: missing_tracks: medium unmatched_tracks: medium distance_weights: data_source: 2.0 artist: 3.0 album: 3.0 media: 1.0 mediums: 1.0 year: 1.0 country: 0.5 label: 0.5 catalognum: 0.5 albumdisambig: 0.5 album_id: 5.0 tracks: 2.0 missing_tracks: 0.9 unmatched_tracks: 0.6 track_title: 3.0 track_artist: 2.0 track_index: 1.0 track_length: 2.0 track_id: 5.0 medium: 1.0 preferred: countries: [] media: [] original_year: no ignored: [] required: [] ignored_media: [] ignore_data_tracks: yes ignore_video_tracks: yes track_length_grace: 10 track_length_max: 30 album_disambig_fields: data_source media year country label catalognum albumdisambig singleton_disambig_fields: data_source index track_alt album ================================================ FILE: beets/dbcore/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """DBCore is an abstract database package that forms the basis for beets' Library. """ from .db import Database, Index, Model, Results from .query import ( AndQuery, FieldQuery, InvalidQueryError, MatchQuery, OrQuery, Query, ) from .queryparse import ( parse_sorted_query, query_from_strings, sort_from_strings, ) from .types import Type __all__ = [ "AndQuery", "Database", "FieldQuery", "Index", "InvalidQueryError", "MatchQuery", "Model", "OrQuery", "Query", "Results", "Type", "parse_sorted_query", "query_from_strings", "sort_from_strings", ] ================================================ FILE: beets/dbcore/db.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The central Model and Database constructs for DBCore.""" from __future__ import annotations import functools import os import re import sqlite3 import sys import threading import time from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Mapping from contextlib import contextmanager from dataclasses import dataclass from functools import cached_property from sqlite3 import Connection, sqlite_version_info from typing import ( TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, Literal, NamedTuple, TypedDict, ) from typing_extensions import ( Self, TypeVar, # default value support ) from unidecode import unidecode import beets from ..util import cached_classproperty, functemplate from . import types from .query import MatchQuery, NullSort, TrueQuery if TYPE_CHECKING: from collections.abc import ( Callable, Generator, Iterable, Iterator, Sequence, ) from sqlite3 import Connection from types import TracebackType from .query import FieldQueryType, FieldSort, Query, Sort, SQLiteType D = TypeVar("D", bound="Database", default=Any) FlexAttrs = dict[str, str] class DBAccessError(Exception): """The SQLite database became inaccessible. This can happen when trying to read or write the database when, for example, the database file is deleted or otherwise disappears. There is probably no way to recover from this error. """ class DBCustomFunctionError(Exception): """A sqlite function registered by beets failed.""" def __init__(self): super().__init__( "beets defined SQLite function failed; " "see the other errors above for details" ) class NotFoundError(LookupError): pass class FormattedMapping(Mapping[str, str]): """A `dict`-like formatted view of a model. The accessor `mapping[key]` returns the formatted version of `model[key]` as a unicode string. The `included_keys` parameter allows filtering the fields that are returned. By default all fields are returned. Limiting to specific keys can avoid expensive per-item database queries. If `for_path` is true, all path separators in the formatted values are replaced. """ model: Model ALL_KEYS = "*" def __init__( self, model: Model, included_keys: str = ALL_KEYS, for_path: bool = False, ): self.for_path = for_path self.model = model if included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. self.model_keys = self.model.keys(True) else: self.model_keys = included_keys def __getitem__(self, key: str) -> str: if key in self.model_keys: return self._get_formatted(self.model, key) else: raise KeyError(key) def __iter__(self) -> Iterator[str]: return iter(self.model_keys) def __len__(self) -> int: return len(self.model_keys) # The following signature is incompatible with `Mapping[str, str]`, since # the return type doesn't include `None` (but `default` can be `None`). def get( # type: ignore self, key: str, default: str | None = None, ) -> str: """Similar to Mapping.get(key, default), but always formats to str.""" if default is None: default = self.model._type(key).format(None) return super().get(key, default) def _get_formatted(self, model: Model, key: str) -> str: value = model._type(key).format(model.get(key)) if isinstance(value, bytes): value = value.decode("utf-8", "ignore") if self.for_path: sep_repl: str = beets.config["path_sep_replace"].as_str() sep_drive: str = beets.config["drive_sep_replace"].as_str() if re.match(r"^[a-zA-Z]:", value): value = re.sub(r"(?<=[a-zA-Z]):", sep_drive, value) for sep in (os.path.sep, os.path.altsep): if sep: value = value.replace(sep, sep_repl) return value # NOTE: This seems like it should be a `Mapping`, i.e. # ``` # class LazyConvertDict(Mapping[str, Any]) # ``` # but there are some conflicts with the `Mapping` protocol such that we # can't do this without changing behaviour: In particular, iterators returned # by some methods build intermediate lists, such that modification of the # `LazyConvertDict` becomes safe during iteration. Some code does in fact rely # on this. class LazyConvertDict: """Lazily convert types for attributes fetched from the database""" def __init__(self, model_cls: Model): """Initialize the object empty""" # FIXME: Dict[str, SQLiteType] self._data: dict[str, Any] = {} self.model_cls = model_cls self._converted: dict[str, Any] = {} def init(self, data: dict[str, Any]): """Set the base data that should be lazily converted""" self._data = data def _convert(self, key: str, value: Any): """Convert the attribute type according to the SQL type""" return self.model_cls._type(key).from_sql(value) def __setitem__(self, key: str, value: Any): """Set an attribute value, assume it's already converted""" self._converted[key] = value def __getitem__(self, key: str) -> Any: """Get an attribute value, converting the type on demand if needed """ if key in self._converted: return self._converted[key] elif key in self._data: value = self._convert(key, self._data[key]) self._converted[key] = value return value def __delitem__(self, key: str): """Delete both converted and base data""" if key in self._converted: del self._converted[key] if key in self._data: del self._data[key] def keys(self) -> list[str]: """Get a list of available field names for this object.""" return list(self._converted.keys()) + list(self._data.keys()) def copy(self) -> LazyConvertDict: """Create a copy of the object.""" new = self.__class__(self.model_cls) new._data = self._data.copy() new._converted = self._converted.copy() return new # Act like a dictionary. def update(self, values: Mapping[str, Any]): """Assign all values in the given dict.""" for key, value in values.items(): self[key] = value def items(self) -> Iterable[tuple[str, Any]]: """Iterate over (key, value) pairs that this object contains. Computed fields are not included. """ for key in self: yield key, self[key] def get(self, key: str, default: Any | None = None): """Get the value for a given key or `default` if it does not exist. """ if key in self: return self[key] else: return default def __contains__(self, key: Any) -> bool: """Determine whether `key` is an attribute on this object.""" return key in self._converted or key in self._data def __iter__(self) -> Iterator[str]: """Iterate over the available field names (excluding computed fields). """ # NOTE: It would be nice to use the following: # yield from self._converted # yield from self._data # but that won't work since some code relies on modifying `self` # during iteration. return iter(self.keys()) def __len__(self) -> int: # FIXME: This is incorrect due to duplication of keys return len(self._converted) + len(self._data) # Abstract base for model classes. class Model(ABC, Generic[D]): """An abstract object representing an object in the database. Model objects act like dictionaries (i.e., they allow subscript access like ``obj['field']``). The same field set is available via attribute access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are available: * **Fixed attributes** come from a predetermined list of field names. These fields correspond to SQLite table columns and are thus fast to read, write, and query. * **Flexible attributes** are free-form and do not need to be listed ahead of time. * **Computed attributes** are read-only fields computed by a getter function provided by a plugin. Access to all three field types is uniform: ``obj.field`` works the same regardless of whether ``field`` is fixed, flexible, or computed. Model objects can optionally be associated with a `Library` object, in which case they can be loaded and stored from the database. Dirty flags are used to track which fields need to be stored. """ # Abstract components (to be provided by subclasses). _table: str """The main SQLite table name. """ _flex_table: str """The flex field SQLite table name. """ _fields: ClassVar[dict[str, types.Type]] = {} """A mapping indicating available "fixed" fields on this type. The keys are field names and the values are `Type` objects. """ _search_fields: Sequence[str] = () """The fields that should be queried by default by unqualified query terms. """ _indices: Sequence[Index] = () """A sequence of `Index` objects that describe the indices to be created for this table. """ @cached_classproperty def _types(cls) -> dict[str, types.Type]: """Optional types for non-fixed (flexible and computed) fields.""" return {} _sorts: ClassVar[dict[str, type[FieldSort]]] = {} """Optional named sort criteria. The keys are strings and the values are subclasses of `Sort`. """ @cached_classproperty def _queries(cls) -> dict[str, FieldQueryType]: """Named queries that use a field-like `name:value` syntax but which do not relate to any specific field. """ return {} _always_dirty = False """By default, fields only become "dirty" when their value actually changes. Enabling this flag marks fields as dirty even when the new value is the same as the old value (e.g., `o.f = o.f`). """ _revision = -1 """A revision number from when the model was loaded from or written to the database. """ @cached_classproperty def _relation(cls): """The model that this model is closely related to.""" return cls @cached_classproperty def relation_join(cls) -> str: """Return the join required to include the related table in the query. This is intended to be used as a FROM clause in the SQL query. """ return "" @cached_classproperty def all_db_fields(cls) -> set[str]: return cls._fields.keys() | cls._relation._fields.keys() @cached_classproperty def shared_db_fields(cls) -> set[str]: return cls._fields.keys() & cls._relation._fields.keys() @cached_classproperty def other_db_fields(cls) -> set[str]: """Fields in the related table.""" return cls._relation._fields.keys() - cls.shared_db_fields @cached_property def db(self) -> D: """Get the database associated with this object. This validates that the database is attached and the object has an id. """ return self._check_db() def get_fresh_from_db(self) -> Self: """Load this object from the database.""" model_cls = self.__class__ if obj := self.db._get(model_cls, self.id): return obj raise NotFoundError(f"No matching {model_cls.__name__} found") from None @classmethod def _getters(cls: type[Model]): """Return a mapping from field names to getter functions.""" # We could cache this if it becomes a performance problem to # gather the getter mapping every time. raise NotImplementedError() def _template_funcs(self) -> Mapping[str, Callable[[str], str]]: """Return a mapping from function names to text-transformer functions. """ # As above: we could consider caching this result. raise NotImplementedError() # Basic operation. def __init__(self, db: D | None = None, **values): """Create a new object with an optional Database association and initial field values. """ self._db = db self._dirty: set[str] = set() self._values_fixed = LazyConvertDict(self) self._values_flex = LazyConvertDict(self) # Initial contents. self.update(values) self.clear_dirty() @classmethod def _awaken( cls: type[AnyModel], db: D | None = None, fixed_values: dict[str, Any] = {}, flex_values: dict[str, Any] = {}, ) -> AnyModel: """Create an object with values drawn from the database. This is a performance optimization: the checks involved with ordinary construction are bypassed. """ obj = cls(db) obj._values_fixed.init(fixed_values) obj._values_flex.init(flex_values) return obj def __repr__(self) -> str: return ( f"{type(self).__name__}" f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})" ) def clear_dirty(self): """Mark all fields as *clean* (i.e., not needing to be stored to the database). Also update the revision. """ self._dirty = set() if self._db: self._revision = self._db.revision def _check_db(self, need_id: bool = True) -> D: """Ensure that this object is associated with a database row: it has a reference to a database (`_db`) and an id. A ValueError exception is raised otherwise. """ if not self._db: raise ValueError(f"{type(self).__name__} has no database") if need_id and not self.id: raise ValueError(f"{type(self).__name__} has no id") return self._db def copy(self) -> Model: """Create a copy of the model object. The field values and other state is duplicated, but the new copy remains associated with the same database as the old object. (A simple `copy.deepcopy` will not work because it would try to duplicate the SQLite connection.) """ new = self.__class__() new._db = self._db new._values_fixed = self._values_fixed.copy() new._values_flex = self._values_flex.copy() new._dirty = self._dirty.copy() return new # Essential field accessors. @classmethod def _type(cls, key) -> types.Type: """Get the type of a field, a `Type` instance. If the field has no explicit type, it is given the base `Type`, which does no conversion. """ return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT def _get(self, key, default: Any = None, raise_: bool = False): """Get the value for a field, or `default`. Alternatively, raise a KeyError if the field is not available. """ getters = self._getters() if key in getters: # Computed. return getters[key](self) elif key in self._fields: # Fixed. if key in self._values_fixed: return self._values_fixed[key] else: return self._type(key).null elif key in self._values_flex: # Flexible. return self._values_flex[key] elif raise_: raise KeyError(key) else: return default get = _get def __getitem__(self, key): """Get the value for a field. Raise a KeyError if the field is not available. """ return self._get(key, raise_=True) def _setitem(self, key, value): """Assign the value for a field, return whether new and old value differ. """ # Choose where to place the value. if key in self._fields: source = self._values_fixed else: source = self._values_flex # If the field has a type, filter the value. value = self._type(key).normalize(value) # Assign value and possibly mark as dirty. old_value = source.get(key) source[key] = value changed = old_value != value if self._always_dirty or changed: self._dirty.add(key) return changed def __setitem__(self, key, value): """Assign the value for a field.""" self._setitem(key, value) def __delitem__(self, key): """Remove a flexible attribute from the model.""" if key in self._values_flex: # Flexible. del self._values_flex[key] self._dirty.add(key) # Mark for dropping on store. elif key in self._fields: # Fixed setattr(self, key, self._type(key).null) elif key in self._getters(): # Computed. raise KeyError(f"computed field {key} cannot be deleted") else: raise KeyError(f"no such field {key}") def keys(self, computed: bool = False): """Get a list of available field names for this object. The `computed` parameter controls whether computed (plugin-provided) fields are included in the key list. """ base_keys = list(self._fields) + list(self._values_flex.keys()) if computed: return base_keys + list(self._getters().keys()) else: return base_keys @classmethod def all_keys(cls): """Get a list of available keys for objects of this type. Includes fixed and computed fields. """ return list(cls._fields) + list(cls._getters().keys()) # Act like a dictionary. def update(self, values): """Assign all values in the given dict.""" for key, value in values.items(): self[key] = value def items(self) -> Iterator[tuple[str, Any]]: """Iterate over (key, value) pairs that this object contains. Computed fields are not included. """ for key in self: yield key, self[key] def __contains__(self, key) -> bool: """Determine whether `key` is an attribute on this object.""" return key in self.keys(computed=True) def __iter__(self) -> Iterator[str]: """Iterate over the available field names (excluding computed fields). """ return iter(self.keys()) # Convenient attribute access. def __getattr__(self, key): if key.startswith("_"): raise AttributeError(f"model has no attribute {key!r}") else: try: return self[key] except KeyError: raise AttributeError(f"no such field {key!r}") def __setattr__(self, key, value): if key.startswith("_"): super().__setattr__(key, value) else: self[key] = value def __delattr__(self, key): if key.startswith("_"): super().__delattr__(key) else: del self[key] # Database interaction (CRUD methods). def store(self, fields: Iterable[str] | None = None): """Save the object's metadata into the library database. :param fields: the fields to be stored. If not specified, all fields will be. """ if fields is None: fields = self._fields # Build assignments for query. assignments = [] subvars: list[SQLiteType] = [] for key in fields: if key != "id" and key in self._dirty: self._dirty.remove(key) assignments.append(f"{key}=?") value = self._type(key).to_sql(self[key]) subvars.append(value) with self.db.transaction() as tx: # Main table update. if assignments: query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?" subvars.append(self.id) tx.mutate(query, subvars) # Modified/added flexible attributes. for key, value in self._values_flex.items(): if key in self._dirty: self._dirty.remove(key) value = self._type(key).to_sql(value) tx.mutate( f"INSERT INTO {self._flex_table} " "(entity_id, key, value) " "VALUES (?, ?, ?);", (self.id, key, value), ) # Deleted flexible attributes. for key in self._dirty: tx.mutate( f"DELETE FROM {self._flex_table} WHERE entity_id=? AND key=?", (self.id, key), ) self.clear_dirty() def load(self): """Refresh the object's metadata from the library database. If check_revision is true, the database is only queried loaded when a transaction has been committed since the item was last loaded. """ if not self._dirty and self.db.revision == self._revision: # Exit early return self.__dict__.update(self.get_fresh_from_db().__dict__) self.clear_dirty() def remove(self): """Remove the object's associated rows from the database.""" with self.db.transaction() as tx: tx.mutate(f"DELETE FROM {self._table} WHERE id=?", (self.id,)) tx.mutate( f"DELETE FROM {self._flex_table} WHERE entity_id=?", (self.id,) ) def add(self, db: D | None = None): """Add the object to the library database. This object must be associated with a database; you can provide one via the `db` parameter or use the currently associated database. The object's `id` and `added` fields are set along with any current field values. """ if db: self._db = db db = self._check_db(need_id=False) with db.transaction() as tx: new_id = tx.mutate(f"INSERT INTO {self._table} DEFAULT VALUES") self.id = new_id self.added = time.time() # Mark every non-null field as dirty and store. for key in self: if self[key] is not None: self._dirty.add(key) self.store() # Formatting and templating. _formatter = FormattedMapping def formatted( self, included_keys: str = _formatter.ALL_KEYS, for_path: bool = False, ) -> FormattedMapping: """Get a mapping containing all values on this object formatted as human-readable unicode strings. """ return self._formatter(self, included_keys, for_path) def evaluate_template( self, template: str | functemplate.Template, for_path: bool = False, ) -> str: """Evaluate a template (a string or a `Template` object) using the object's fields. If `for_path` is true, then no new path separators will be added to the template. """ # Perform substitution. if isinstance(template, str): t = functemplate.template(template) else: # Help out mypy t = template return t.substitute( self.formatted(for_path=for_path), self._template_funcs() ) # Parsing. @classmethod def _parse(cls, key, string: str) -> Any: """Parse a string as a value for the given key.""" if not isinstance(string, str): raise TypeError("_parse() argument must be a string") return cls._type(key).parse(string) def set_parse(self, key, string: str): """Set the object's key to a value represented by a string.""" self[key] = self._parse(key, string) def __getstate__(self): """Return the state of the object for pickling. Remove the database connection as sqlite connections are not picklable. """ return { k: v for k, v in self.__dict__.items() if k not in {"_db", "db"} } # Database controller and supporting interfaces. AnyModel = TypeVar("AnyModel", bound=Model) class Results(Generic[AnyModel]): """An item query result set. Iterating over the collection lazily constructs Model objects that reflect database rows. """ def __init__( self, model_class: type[AnyModel], rows: list[sqlite3.Row], db: D, flex_rows, query: Query | None = None, sort=None, ): """Create a result set that will construct objects of type `model_class`. `model_class` is a subclass of `Model` that will be constructed. `rows` is a query result: a list of mappings. The new objects will be associated with the database `db`. If `query` is provided, it is used as a predicate to filter the results for a "slow query" that cannot be evaluated by the database directly. If `sort` is provided, it is used to sort the full list of results before returning. This means it is a "slow sort" and all objects must be built before returning the first one. """ self.model_class = model_class self.rows = rows self.db = db self.query = query self.sort = sort self.flex_rows = flex_rows # We keep a queue of rows we haven't yet consumed for # materialization. We preserve the original total number of # rows. self._rows = rows self._row_count = len(rows) # The materialized objects corresponding to rows that have been # consumed. self._objects: list[AnyModel] = [] def _get_objects(self) -> Iterator[AnyModel]: """Construct and generate Model objects for they query. The objects are returned in the order emitted from the database; no slow sort is applied. For performance, this generator caches materialized objects to avoid constructing them more than once. This way, iterating over a `Results` object a second time should be much faster than the first. """ # Index flexible attributes by the item ID, so we have easier access flex_attrs = self._get_indexed_flex_attrs() index = 0 # Position in the materialized objects. while index < len(self._objects) or self._rows: # Are there previously-materialized objects to produce? if index < len(self._objects): yield self._objects[index] index += 1 # Otherwise, we consume another row, materialize its object # and produce it. else: while self._rows: row = self._rows.pop(0) obj = self._make_model(row, flex_attrs.get(row["id"], {})) # If there is a slow-query predicate, ensurer that the # object passes it. if not self.query or self.query.match(obj): self._objects.append(obj) index += 1 yield obj break def __iter__(self) -> Iterator[AnyModel]: """Construct and generate Model objects for all matching objects, in sorted order. """ if self.sort: # Slow sort. Must build the full list first. objects = self.sort.sort(list(self._get_objects())) return iter(objects) else: # Objects are pre-sorted (i.e., by the database). return self._get_objects() def _get_indexed_flex_attrs(self) -> dict[int, FlexAttrs]: """Index flexible attributes by the entity id they belong to""" flex_values: dict[int, FlexAttrs] = {} for row in self.flex_rows: if row["entity_id"] not in flex_values: flex_values[row["entity_id"]] = {} flex_values[row["entity_id"]][row["key"]] = row["value"] return flex_values def _make_model( self, row: sqlite3.Row, flex_values: FlexAttrs = {} ) -> AnyModel: """Create a Model object for the given row""" cols = dict(row) values = {k: v for (k, v) in cols.items() if not k[:4] == "flex"} # Construct the Python object obj = self.model_class._awaken(self.db, values, flex_values) return obj def __len__(self) -> int: """Get the number of matching objects.""" if not self._rows: # Fully materialized. Just count the objects. return len(self._objects) elif self.query: # A slow query. Fall back to testing every object. count = 0 for obj in self: count += 1 return count else: # A fast query. Just count the rows. return self._row_count def __nonzero__(self) -> bool: """Does this result contain any objects?""" return self.__bool__() def __bool__(self) -> bool: """Does this result contain any objects?""" return bool(len(self)) def __getitem__(self, n): """Get the nth item in this result set. This is inefficient: all items up to n are materialized and thrown away. """ if not self._rows and not self.sort: # Fully materialized and already in order. Just look up the # object. return self._objects[n] it = iter(self) try: for i in range(n): next(it) return next(it) except StopIteration: raise IndexError(f"result index {n} out of range") def get(self) -> AnyModel | None: """Return the first matching object, or None if no objects match. """ it = iter(self) try: return next(it) except StopIteration: return None class Transaction: """A context manager for safe, concurrent access to the database. All SQL commands should be executed through a transaction. """ _mutated = False """A flag storing whether a mutation has been executed in the current transaction. """ def __init__(self, db: Database): self.db = db def __enter__(self) -> Transaction: """Begin a transaction. This transaction may be created while another is active in a different thread. """ with self.db._tx_stack() as stack: first = not stack stack.append(self) if first: # Beginning a "root" transaction, which corresponds to an # SQLite transaction. self.db._db_lock.acquire() return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> bool | None: """Complete a transaction. This must be the most recently entered but not yet exited transaction. If it is the last active transaction, the database updates are committed. """ # Beware of races; currently secured by db._db_lock self.db.revision += self._mutated with self.db._tx_stack() as stack: assert stack.pop() is self empty = not stack if empty: # Ending a "root" transaction. End the SQLite transaction. self.db._connection().commit() self._mutated = False self.db._db_lock.release() if ( isinstance(exc_value, sqlite3.OperationalError) and exc_value.args[0] == "user-defined function raised exception" ): raise DBCustomFunctionError() return None def query( self, statement: str, subvals: Sequence[SQLiteType] = () ) -> list[sqlite3.Row]: """Execute an SQL statement with substitution values and return a list of rows from the database. """ cursor = self.db._connection().execute(statement, subvals) return cursor.fetchall() @contextmanager def _handle_mutate(self) -> Iterator[None]: """Handle mutation bookkeeping and database access errors. Yield control to mutation execution code. If execution succeeds, mark this transaction as mutated. """ try: yield except sqlite3.OperationalError as e: # In two specific cases, SQLite reports an error while accessing # the underlying database file. We surface these exceptions as # DBAccessError so the application can abort. if e.args[0] in ( "attempt to write a readonly database", "unable to open database file", ): raise DBAccessError(e.args[0]) raise else: self._mutated = True def mutate(self, statement: str, subvals: Sequence[SQLiteType] = ()) -> Any: """Run one write statement with shared mutation/error handling.""" with self._handle_mutate(): return self.db._connection().execute(statement, subvals).lastrowid def mutate_many( self, statement: str, subvals: Sequence[tuple[SQLiteType, ...]] = () ) -> Any: """Run batched writes with shared mutation/error handling.""" with self._handle_mutate(): return ( self.db._connection().executemany(statement, subvals).lastrowid ) def script(self, statements: str): """Execute a string containing multiple SQL statements.""" # We don't know whether this mutates, but quite likely it does. self._mutated = True self.db._connection().executescript(statements) @dataclass class Migration(ABC): """Define a one-time data migration that runs during database startup.""" db: Database @cached_classproperty def name(cls) -> str: """Class name (except Migration) converted to snake case.""" name = cls.__name__.removesuffix("Migration") # type: ignore[attr-defined] return re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name).lower() @contextmanager def with_row_factory(self, factory: type[NamedTuple]) -> Iterator[None]: """Temporarily decode query rows into a typed tuple shape.""" original_factory = self.db._connection().row_factory self.db._connection().row_factory = lambda _, row: factory(*row) try: yield finally: self.db._connection().row_factory = original_factory def migrate_model(self, model_cls: type[Model], *args, **kwargs) -> None: """Run this migration once for a model's backing table.""" table = model_cls._table if not self.db.migration_exists(self.name, table): self._migrate_data(model_cls, *args, **kwargs) self.db.record_migration(self.name, table) @abstractmethod def _migrate_data( self, model_cls: type[Model], current_fields: set[str] ) -> None: """Migrate data for a specific model.""" class TableInfo(TypedDict): columns: set[str] migrations: set[str] class Database: """A container for Model objects that wraps an SQLite database as the backend. """ _models: Sequence[type[Model]] = () """The Model subclasses representing tables in this database. """ _migrations: Sequence[tuple[type[Migration], Sequence[type[Model]]]] = () """Migrations that are to be performed for the configured models.""" supports_extensions = hasattr(sqlite3.Connection, "enable_load_extension") """Whether or not the current version of SQLite supports extensions""" revision = 0 """The current revision of the database. To be increased whenever data is written in a transaction. """ def __init__(self, path, timeout: float = 5.0): if sqlite3.threadsafety == 0: raise RuntimeError( "sqlite3 must be compiled with multi-threading support" ) # Print tracebacks for exceptions in user defined functions # See also `self.add_functions` and `DBCustomFunctionError`. # # `if`: use feature detection because PyPy doesn't support this. if hasattr(sqlite3, "enable_callback_tracebacks"): sqlite3.enable_callback_tracebacks(True) self.path = path self.timeout = timeout self._connections: dict[int, sqlite3.Connection] = {} self._tx_stacks: defaultdict[int, list[Transaction]] = defaultdict(list) self._extensions: list[str] = [] # A lock to protect the _connections and _tx_stacks maps, which # both map thread IDs to private resources. self._shared_map_lock = threading.Lock() # A lock to protect access to the database itself. SQLite does # allow multiple threads to access the database at the same # time, but many users were experiencing crashes related to this # capability: where SQLite was compiled without HAVE_USLEEP, its # backoff algorithm in the case of contention was causing # whole-second sleeps (!) that would trigger its internal # timeout. Using this lock ensures only one SQLite transaction # is active at a time. self._db_lock = threading.Lock() # Set up database schema. self._ensure_migration_state_table() for model_cls in self._models: self._make_table(model_cls._table, model_cls._fields) self._make_attribute_table(model_cls._flex_table) self._create_indices(model_cls._table, model_cls._indices) self._migrate() @cached_property def db_tables(self) -> dict[str, TableInfo]: column_queries = [ f""" SELECT '{m._table}' AS table_name, 'columns' AS source, name FROM pragma_table_info('{m._table}') """ for m in self._models ] with self.transaction() as tx: rows = tx.query(f""" {" UNION ALL ".join(column_queries)} UNION ALL SELECT table_name, 'migrations' AS source, name FROM migrations """) tables_data: dict[str, TableInfo] = defaultdict( lambda: TableInfo(columns=set(), migrations=set()) ) source: Literal["columns", "migrations"] for table_name, source, name in rows: tables_data[table_name][source].add(name) return tables_data # Primitive access control: connections and transactions. def _connection(self) -> Connection: """Get a SQLite connection object to the underlying database. One connection object is created per thread. """ thread_id = threading.current_thread().ident # Help the type checker: ident can only be None if the thread has not # been started yet; but since this results from current_thread(), that # can't happen assert thread_id is not None with self._shared_map_lock: if thread_id in self._connections: return self._connections[thread_id] else: conn = self._create_connection() self._connections[thread_id] = conn return conn def _create_connection(self) -> Connection: """Create a SQLite connection to the underlying database. Makes a new connection every time. If you need to configure the connection settings (e.g., add custom functions), override this method. """ # Make a new connection. The `sqlite3` module can't use # bytestring paths here on Python 3, so we need to # provide a `str` using `os.fsdecode`. conn = sqlite3.connect( os.fsdecode(self.path), timeout=self.timeout, # We have our own same-thread checks in _connection(), but need to # call conn.close() in _close() check_same_thread=False, ) if sys.version_info >= (3, 12) and sqlite3.sqlite_version_info >= ( 3, 29, 0, ): # If possible, disable double-quoted strings conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DDL, 0) conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DML, 0) self.add_functions(conn) if self.supports_extensions: conn.enable_load_extension(True) # Load any extension that are already loaded for other connections. for path in self._extensions: conn.load_extension(path) # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row return conn def add_functions(self, conn): def regexp(value, pattern): if isinstance(value, bytes): value = value.decode() return re.search(pattern, str(value)) is not None def bytelower(bytestring: AnyStr | None) -> AnyStr | None: """A custom ``bytelower`` sqlite function so we can compare bytestrings in a semi case insensitive fashion. This is to work around sqlite builds are that compiled with ``-DSQLITE_LIKE_DOESNT_MATCH_BLOBS``. See ``https://github.com/beetbox/beets/issues/2172`` for details. """ if bytestring is not None: return bytestring.lower() return bytestring create_function = conn.create_function if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3): # Let sqlite make extra optimizations create_function = functools.partial( conn.create_function, deterministic=True ) create_function("regexp", 2, regexp) create_function("unidecode", 1, unidecode) create_function("bytelower", 1, bytelower) def _close(self): """Close the all connections to the underlying SQLite database from all threads. This does not render the database object unusable; new connections can still be opened on demand. """ with self._shared_map_lock: while self._connections: _thread_id, conn = self._connections.popitem() conn.close() @contextmanager def _tx_stack(self) -> Generator[list[Transaction]]: """A context manager providing access to the current thread's transaction stack. The context manager synchronizes access to the stack map. Transactions should never migrate across threads. """ thread_id = threading.current_thread().ident # Help the type checker: ident can only be None if the thread has not # been started yet; but since this results from current_thread(), that # can't happen assert thread_id is not None with self._shared_map_lock: yield self._tx_stacks[thread_id] def transaction(self) -> Transaction: """Get a :class:`Transaction` object for interacting directly with the underlying SQLite database. """ return Transaction(self) def load_extension(self, path: str): """Load an SQLite extension into all open connections.""" if not self.supports_extensions: raise ValueError( "this sqlite3 installation does not support extensions" ) self._extensions.append(path) # Load the extension into every open connection. for conn in self._connections.values(): conn.load_extension(path) # Schema setup and migration. def _make_table(self, table: str, fields: Mapping[str, types.Type]): """Set up the schema of the database. `fields` is a mapping from field names to `Type`s. Columns are added if necessary. """ if table not in self.db_tables: # No table exists. columns = [] for name, typ in fields.items(): columns.append(f"{name} {typ.sql}") setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n" self.db_tables[table]["columns"] = set(fields) else: # Table exists does not match the field set. setup_sql = "" current_fields = self.db_tables[table]["columns"] for name, typ in fields.items(): if name not in current_fields: setup_sql += ( f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n" ) with self.transaction() as tx: tx.script(setup_sql) def _make_attribute_table(self, flex_table: str): """Create a table and associated index for flexible attributes for the given entity (if they don't exist). """ with self.transaction() as tx: tx.script(f""" CREATE TABLE IF NOT EXISTS {flex_table} ( id INTEGER PRIMARY KEY, entity_id INTEGER, key TEXT, value TEXT, UNIQUE(entity_id, key) ON CONFLICT REPLACE); CREATE INDEX IF NOT EXISTS {flex_table}_by_entity ON {flex_table} (entity_id); """) def _create_indices( self, table: str, indices: Sequence[Index], ): """Create indices for the given table if they don't exist.""" with self.transaction() as tx: for index in indices: tx.script( f"CREATE INDEX IF NOT EXISTS {index.name} " f"ON {table} ({', '.join(index.columns)});" ) # Generic migration state handling. def _ensure_migration_state_table(self) -> None: with self.transaction() as tx: tx.script(""" CREATE TABLE IF NOT EXISTS migrations ( name TEXT NOT NULL, table_name TEXT NOT NULL, PRIMARY KEY(name, table_name) ); """) def _migrate(self) -> None: """Perform any necessary migration for the database.""" for migration_cls, model_classes in self._migrations: migration = migration_cls(self) for model_cls in model_classes: migration.migrate_model( model_cls, self.db_tables[model_cls._table]["columns"] ) def migration_exists(self, name: str, table: str) -> bool: """Return whether a named migration has been marked complete.""" return name in self.db_tables[table]["migrations"] def record_migration(self, name: str, table: str) -> None: """Set completion state for a named migration.""" with self.transaction() as tx: tx.mutate( "INSERT INTO migrations(name, table_name) VALUES (?, ?)", (name, table), ) # Querying. def _fetch( self, model_cls: type[AnyModel], query: Query | None = None, sort: Sort | None = None, ) -> Results[AnyModel]: """Fetch the objects of type `model_cls` matching the given query. The query may be given as a string, string sequence, a Query object, or None (to fetch everything). `sort` is an `Sort` object. """ query = query or TrueQuery() # A null query. sort = sort or NullSort() # Unsorted. where, subvals = query.clause() order_by = sort.order_clause() table = model_cls._table _from = table if query.field_names & model_cls.other_db_fields: _from += f" {model_cls.relation_join}" # group by id to avoid duplicates when joining with the relation sql = ( f"SELECT {table}.* " f"FROM ({_from}) " f"WHERE {where or 1} " f"GROUP BY {table}.id" ) # Fetch flexible attributes for items matching the main query. # Doing the per-item filtering in python is faster than issuing # one query per item to sqlite. flex_sql = ( "SELECT * " f"FROM {model_cls._flex_table} " f"WHERE entity_id IN (SELECT id FROM ({sql}))" ) if order_by: # the sort field may exist in both 'items' and 'albums' tables # (when they are joined), causing ambiguous column OperationalError # if we try to order directly. # Since the join is required only for filtering, we can filter in # a subquery and order the result, which returns unique fields. sql = f"SELECT * FROM ({sql}) ORDER BY {order_by}" with self.transaction() as tx: rows = tx.query(sql, subvals) flex_rows = tx.query(flex_sql, subvals) return Results( model_cls, rows, self, flex_rows, None if where else query, # Slow query component. sort if sort.is_slow() else None, # Slow sort component. ) def _get(self, model_cls: type[AnyModel], id_: int) -> AnyModel | None: """Get a Model object by its id or None if the id does not exist.""" return self._fetch(model_cls, MatchQuery("id", id_)).get() class Index(NamedTuple): """A helper class to represent the index information in the database schema. """ name: str columns: tuple[str, ...] ================================================ FILE: beets/dbcore/query.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The Query type hierarchy for DBCore.""" from __future__ import annotations import os import re import unicodedata from abc import ABC, abstractmethod from collections.abc import Sequence from datetime import datetime, timedelta from functools import cached_property, reduce from operator import mul, or_ from re import Pattern from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from beets import util from beets.util.units import raw_seconds_short if TYPE_CHECKING: from collections.abc import Iterator, MutableSequence from beets.dbcore.db import AnyModel, Model P = TypeVar("P", default=Any) else: P = TypeVar("P") # To use the SQLite "blob" type, it doesn't suffice to provide a byte # string; SQLite treats that as encoded text. Wrapping it in a # `memoryview` tells it that we actually mean non-text data. # needs to be defined in here due to circular import. # TODO: remove it from this module and define it in dbcore/types.py instead BLOB_TYPE = memoryview class ParsingError(ValueError): """Abstract class for any unparsable user-requested album/query specification. """ class InvalidQueryError(ParsingError): """Represent any kind of invalid query. The query should be a unicode string or a list, which will be space-joined. """ def __init__(self, query, explanation): if isinstance(query, list): query = " ".join(query) message = f"'{query}': {explanation}" super().__init__(message) class InvalidQueryArgumentValueError(ParsingError): """Represent a query argument that could not be converted as expected. It exists to be caught in upper stack levels so a meaningful (i.e. with the query) InvalidQueryError can be raised. """ def __init__(self, what, expected, detail=None): message = f"'{what}' is not {expected}" if detail: message = f"{message}: {detail}" super().__init__(message) class Query(ABC): """An abstract class representing a query into the database.""" @property def field_names(self) -> set[str]: """Return a set with field names that this query operates on.""" return set() @abstractmethod def clause(self) -> tuple[str | None, Sequence[Any]]: """Generate an SQLite expression implementing the query. Return (clause, subvals) where clause is a valid sqlite WHERE clause implementing the query and subvals is a list of items to be substituted for ?s in the clause. The default implementation returns None, falling back to a slow query using `match()`. """ @abstractmethod def match(self, obj: Model): """Check whether this query matches a given Model. Can be used to perform queries on arbitrary sets of Model. """ def __and__(self, other: Query) -> AndQuery: return AndQuery([self, other]) def __repr__(self) -> str: return f"{self.__class__.__name__}()" def __eq__(self, other) -> bool: return type(self) is type(other) def __hash__(self) -> int: """Minimalistic default implementation of a hash. Given the implementation if __eq__ above, this is certainly correct. """ return hash(type(self)) SQLiteType = str | bytes | float | int | memoryview | None AnySQLiteType = TypeVar("AnySQLiteType", bound=SQLiteType) FieldQueryType = type["FieldQuery"] class FieldQuery(Query, Generic[P]): """An abstract query that searches in a specific field for a pattern. Subclasses must provide a `value_match` class method, which determines whether a certain pattern string matches a certain value string. Subclasses may also provide `col_clause` to implement the same matching functionality in SQLite. """ @property def field(self) -> str: return ( f"{self.table}.{self.field_name}" if self.table else self.field_name ) @property def field_names(self) -> set[str]: """Return a set with field names that this query operates on.""" return {self.field_name} def __init__(self, field_name: str, pattern: P, fast: bool = True): self.table, _, self.field_name = field_name.rpartition(".") self.pattern = pattern self.fast = fast def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: raise NotImplementedError def clause(self) -> tuple[str | None, Sequence[SQLiteType]]: if self.fast: return self.col_clause() else: # Matching a flexattr. This is a slow query. return None, () @classmethod def value_match(cls, pattern: P, value: Any): """Determine whether the value matches the pattern.""" raise NotImplementedError def match(self, obj: Model) -> bool: return self.value_match(self.pattern, obj.get(self.field_name)) def __repr__(self) -> str: return ( f"{self.__class__.__name__}({self.field_name!r}, {self.pattern!r}, " f"fast={self.fast})" ) def __eq__(self, other) -> bool: return ( super().__eq__(other) and self.field_name == other.field_name and self.pattern == other.pattern ) def __hash__(self) -> int: return hash((self.field_name, hash(self.pattern))) class MatchQuery(FieldQuery[AnySQLiteType]): """A query that looks for exact matches in an Model field.""" def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: return f"{self.field} = ?", [self.pattern] @classmethod def value_match(cls, pattern: AnySQLiteType, value: Any) -> bool: return pattern == value class NoneQuery(FieldQuery[None]): """A query that checks whether a field is null.""" def __init__(self, field, fast: bool = True): super().__init__(field, None, fast) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: return f"{self.field} IS NULL", () def match(self, obj: Model) -> bool: return obj.get(self.field_name) is None def __repr__(self) -> str: return f"{self.__class__.__name__}({self.field_name!r}, {self.fast})" class StringFieldQuery(FieldQuery[P]): """A FieldQuery that converts values to strings before matching them. """ @classmethod def value_match(cls, pattern: P, value: Any): """Determine whether the value matches the pattern. The value may have any type. """ return cls.string_match(pattern, util.as_string(value)) @classmethod def string_match( cls, pattern: P, value: str, ) -> bool: """Determine whether the value matches the pattern. Both arguments are strings. Subclasses implement this method. """ raise NotImplementedError class StringQuery(StringFieldQuery[str]): """A query that matches a whole string in a specific Model field.""" def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: search = ( self.pattern.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_") ) clause = f"{self.field} like ? escape '\\'" subvals = [search] return clause, subvals @classmethod def string_match(cls, pattern: str, value: str) -> bool: return pattern.lower() == value.lower() class SubstringQuery(StringFieldQuery[str]): """A query that matches a substring in a specific Model field.""" def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: pattern = ( self.pattern.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_") ) search = f"%{pattern}%" clause = f"{self.field} like ? escape '\\'" subvals = [search] return clause, subvals @classmethod def string_match(cls, pattern: str, value: str) -> bool: return pattern.lower() in value.lower() class PathQuery(FieldQuery[bytes]): """A query that matches all items under a given path. Matching can either be case-insensitive or case-sensitive. By default, the behavior depends on the OS: case-insensitive on Windows and case-sensitive otherwise. """ def __init__(self, field: str, pattern: bytes, fast: bool = True) -> None: """Create a path query. `pattern` must be a path, either to a file or a directory. """ path = util.normpath(pattern) # Case sensitivity depends on the filesystem that the query path is located on. self.case_sensitive = util.case_sensitive(path) # Use a normalized-case pattern for case-insensitive matches. if not self.case_sensitive: # We need to lowercase the entire path, not just the pattern. # In particular, on Windows, the drive letter is otherwise not # lowercased. # This also ensures that the `match()` method below and the SQL # from `col_clause()` do the same thing. path = path.lower() super().__init__(field, path, fast) @cached_property def dir_path(self) -> bytes: return os.path.join(self.pattern, b"") @staticmethod def is_path_query(query_part: str) -> bool: """Try to guess whether a unicode query part is a path query. The path query must 1. precede the colon in the query, if a colon is present 2. contain either ``os.sep`` or ``os.altsep`` (Windows) 3. this path must exist on the filesystem. """ query_part = query_part.split(":")[0] return ( # make sure the query part contains a path separator bool(set(query_part) & {os.sep, os.altsep}) and os.path.exists(util.normpath(query_part)) ) def match(self, obj: Model) -> bool: """Check whether a model object's path matches this query. Performs either an exact match against the pattern or checks if the path starts with the given directory path. Case sensitivity depends on the object's filesystem as determined during initialization. """ path = obj.path if self.case_sensitive else obj.path.lower() return (path == self.pattern) or path.startswith(self.dir_path) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: """Generate an SQL clause that implements path matching in the database. Returns a tuple of SQL clause string and parameter values list that matches paths either exactly or by directory prefix. Handles case sensitivity appropriately using BYTELOWER for case-insensitive matches. """ if self.case_sensitive: left, right = self.field, "?" else: left, right = f"BYTELOWER({self.field})", "BYTELOWER(?)" return f"({left} = {right}) || (substr({left}, 1, ?) = {right})", [ BLOB_TYPE(self.pattern), len(dir_blob := BLOB_TYPE(self.dir_path)), dir_blob, ] def __repr__(self) -> str: return ( f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, " f"fast={self.fast}, case_sensitive={self.case_sensitive})" ) class RegexpQuery(StringFieldQuery[Pattern[str]]): """A query that matches a regular expression in a specific Model field. Raises InvalidQueryError when the pattern is not a valid regular expression. """ def __init__(self, field_name: str, pattern: str, fast: bool = True): pattern = self._normalize(pattern) try: pattern_re = re.compile(pattern) except re.error as exc: # Invalid regular expression. raise InvalidQueryArgumentValueError( pattern, "a regular expression", format(exc) ) super().__init__(field_name, pattern_re, fast) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: return f" regexp({self.field}, ?)", [self.pattern.pattern] @staticmethod def _normalize(s: str) -> str: """Normalize a Unicode string's representation (used on both patterns and matched values). """ return unicodedata.normalize("NFC", s) @classmethod def string_match(cls, pattern: Pattern[str], value: str) -> bool: return pattern.search(cls._normalize(value)) is not None class BooleanQuery(MatchQuery[int]): """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. """ def __init__( self, field_name: str, pattern: bool, fast: bool = True, ): if isinstance(pattern, str): pattern = util.str2bool(pattern) pattern_int = int(pattern) super().__init__(field_name, pattern_int, fast) class NumericQuery(FieldQuery[str]): """Matches numeric fields. A syntax using Ruby-style range ellipses (``..``) lets users specify one- or two-sided ranges. For example, ``year:2001..`` finds music released since the turn of the century. Raises InvalidQueryError when the pattern does not represent an int or a float. """ def _convert(self, s: str) -> float | int | None: """Convert a string to a numeric type (float or int). Return None if `s` is empty. Raise an InvalidQueryError if the string cannot be converted. """ # This is really just a bit of fun premature optimization. if not s: return None try: return int(s) except ValueError: try: return float(s) except ValueError: raise InvalidQueryArgumentValueError(s, "an int or a float") def __init__(self, field_name: str, pattern: str, fast: bool = True): super().__init__(field_name, pattern, fast) parts = pattern.split("..", 1) if len(parts) == 1: # No range. self.point = self._convert(parts[0]) self.rangemin = None self.rangemax = None else: # One- or two-sided range. self.point = None self.rangemin = self._convert(parts[0]) self.rangemax = self._convert(parts[1]) def match(self, obj: Model) -> bool: if self.field_name not in obj: return False value = obj[self.field_name] if isinstance(value, str): value = self._convert(value) if self.point is not None: return value == self.point else: if self.rangemin is not None and value < self.rangemin: return False if self.rangemax is not None and value > self.rangemax: return False return True def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: if self.point is not None: return f"{self.field}=?", (self.point,) else: if self.rangemin is not None and self.rangemax is not None: return ( f"{self.field} >= ? AND {self.field} <= ?", (self.rangemin, self.rangemax), ) elif self.rangemin is not None: return f"{self.field} >= ?", (self.rangemin,) elif self.rangemax is not None: return f"{self.field} <= ?", (self.rangemax,) else: return "1", () class InQuery(Generic[AnySQLiteType], FieldQuery[Sequence[AnySQLiteType]]): """Query which matches values in the given set.""" field_name: str pattern: Sequence[AnySQLiteType] fast: bool = True @property def subvals(self) -> Sequence[SQLiteType]: return self.pattern def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: placeholders = ", ".join(["?"] * len(self.subvals)) return f"{self.field_name} IN ({placeholders})", self.subvals @classmethod def value_match( cls, pattern: Sequence[AnySQLiteType], value: AnySQLiteType ) -> bool: return value in pattern class CollectionQuery(Query): """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ @property def field_names(self) -> set[str]: """Return a set with field names that this query operates on.""" return reduce(or_, (sq.field_names for sq in self.subqueries)) def __init__(self, subqueries: Sequence[Query] = ()): self.subqueries = subqueries # Act like a sequence. def __len__(self) -> int: return len(self.subqueries) def __getitem__(self, key): return self.subqueries[key] def __iter__(self) -> Iterator[Query]: return iter(self.subqueries) def __contains__(self, subq) -> bool: return subq in self.subqueries def clause_with_joiner( self, joiner: str, ) -> tuple[str | None, Sequence[SQLiteType]]: """Return a clause created by joining together the clauses of all subqueries with the string joiner (padded by spaces). """ clause_parts = [] subvals: list[SQLiteType] = [] for subq in self.subqueries: subq_clause, subq_subvals = subq.clause() if not subq_clause: # Fall back to slow query. return None, () clause_parts.append(f"({subq_clause})") subvals += subq_subvals clause = f" {joiner} ".join(clause_parts) return clause, subvals def __repr__(self) -> str: return f"{self.__class__.__name__}({self.subqueries!r})" def __eq__(self, other) -> bool: return super().__eq__(other) and self.subqueries == other.subqueries def __hash__(self) -> int: """Since subqueries are mutable, this object should not be hashable. However and for conveniences purposes, it can be hashed. """ return reduce(mul, map(hash, self.subqueries), 1) class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the query is initialized. """ subqueries: MutableSequence[Query] def __setitem__(self, key, value): self.subqueries[key] = value def __delitem__(self, key): del self.subqueries[key] class AndQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" def clause(self) -> tuple[str | None, Sequence[SQLiteType]]: return self.clause_with_joiner("and") def match(self, obj: Model) -> bool: return all(q.match(obj) for q in self.subqueries) class OrQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" def clause(self) -> tuple[str | None, Sequence[SQLiteType]]: return self.clause_with_joiner("or") def match(self, obj: Model) -> bool: return any(q.match(obj) for q in self.subqueries) class NotQuery(Query): """A query that matches the negation of its `subquery`, as a shortcut for performing `not(subquery)` without using regular expressions. """ @property def field_names(self) -> set[str]: """Return a set with field names that this query operates on.""" return self.subquery.field_names def __init__(self, subquery): self.subquery = subquery def clause(self) -> tuple[str | None, Sequence[SQLiteType]]: clause, subvals = self.subquery.clause() if clause: return f"not ({clause})", subvals else: # If there is no clause, there is nothing to negate. All the logic # is handled by match() for slow queries. return clause, subvals def match(self, obj: Model) -> bool: return not self.subquery.match(obj) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.subquery!r})" def __eq__(self, other) -> bool: return super().__eq__(other) and self.subquery == other.subquery def __hash__(self) -> int: return hash(("not", hash(self.subquery))) class TrueQuery(Query): """A query that always matches.""" def clause(self) -> tuple[str, Sequence[SQLiteType]]: return "1", () def match(self, obj: Model) -> bool: return True class FalseQuery(Query): """A query that never matches.""" def clause(self) -> tuple[str, Sequence[SQLiteType]]: return "0", () def match(self, obj: Model) -> bool: return False # Time/date queries. def _parse_periods(pattern: str) -> tuple[Period | None, Period | None]: """Parse a string containing two dates separated by two dots (..). Return a pair of `Period` objects. """ parts = pattern.split("..", 1) if len(parts) == 1: instant = Period.parse(parts[0]) return (instant, instant) else: start = Period.parse(parts[0]) end = Period.parse(parts[1]) return (start, end) class Period: """A period of time given by a date, time and precision. Example: 2014-01-01 10:50:30 with precision 'month' represents all instants of time during January 2014. """ precisions = ("year", "month", "day", "hour", "minute", "second") date_formats = ( ("%Y",), # year ("%Y-%m",), # month ("%Y-%m-%d",), # day ("%Y-%m-%dT%H", "%Y-%m-%d %H"), # hour ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"), # minute ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"), # second ) relative_units: ClassVar[dict[str, int]] = { "y": 365, "m": 30, "w": 7, "d": 1, } relative_re = "(?P[+|-]?)(?P[0-9]+)(?P[y|m|w|d])" def __init__(self, date: datetime, precision: str): """Create a period with the given date (a `datetime` object) and precision (a string, one of "year", "month", "day", "hour", "minute", or "second"). """ if precision not in Period.precisions: raise ValueError(f"Invalid precision {precision}") self.date = date self.precision = precision @classmethod def parse(cls: type[Period], string: str) -> Period | None: """Parse a date and return a `Period` object or `None` if the string is empty, or raise an InvalidQueryArgumentValueError if the string cannot be parsed to a date. The date may be absolute or relative. Absolute dates look like `YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative dates have three parts: - Optionally, a ``+`` or ``-`` sign indicating the future or the past. The default is the future. - A number: how much to add or subtract. - A letter indicating the unit: days, weeks, months or years (``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days and a "year" is exactly 365 days. """ def find_date_and_format( string: str, ) -> tuple[None, None] | tuple[datetime, int]: for ord, format in enumerate(cls.date_formats): for format_option in format: try: date = datetime.strptime(string, format_option) return date, ord except ValueError: # Parsing failed. pass return (None, None) if not string: return None date: datetime | None # Check for a relative date. match_dq = re.match(cls.relative_re, string) if match_dq: sign = match_dq.group("sign") quantity = match_dq.group("quantity") timespan = match_dq.group("timespan") # Add or subtract the given amount of time from the current # date. multiplier = -1 if sign == "-" else 1 days = cls.relative_units[timespan] date = ( datetime.now() + timedelta(days=int(quantity) * days) * multiplier ) return cls(date, cls.precisions[5]) # Check for an absolute date. date, ordinal = find_date_and_format(string) if date is None or ordinal is None: raise InvalidQueryArgumentValueError( string, "a valid date/time string" ) precision = cls.precisions[ordinal] return cls(date, precision) def open_right_endpoint(self) -> datetime: """Based on the precision, convert the period to a precise `datetime` for use as a right endpoint in a right-open interval. """ precision = self.precision date = self.date if "year" == self.precision: return date.replace(year=date.year + 1, month=1) elif "month" == precision: if date.month < 12: return date.replace(month=date.month + 1) else: return date.replace(year=date.year + 1, month=1) elif "day" == precision: return date + timedelta(days=1) elif "hour" == precision: return date + timedelta(hours=1) elif "minute" == precision: return date + timedelta(minutes=1) elif "second" == precision: return date + timedelta(seconds=1) else: raise ValueError(f"unhandled precision {precision}") class DateInterval: """A closed-open interval of dates. A left endpoint of None means since the beginning of time. A right endpoint of None means towards infinity. """ def __init__(self, start: datetime | None, end: datetime | None): if start is not None and end is not None and not start < end: raise ValueError(f"start date {start} is not before end date {end}") self.start = start self.end = end @classmethod def from_periods( cls, start: Period | None, end: Period | None, ) -> DateInterval: """Create an interval with two Periods as the endpoints.""" end_date = end.open_right_endpoint() if end is not None else None start_date = start.date if start is not None else None return cls(start_date, end_date) def contains(self, date: datetime) -> bool: if self.start is not None and date < self.start: return False if self.end is not None and date >= self.end: return False return True def __str__(self) -> str: return f"[{self.start}, {self.end})" class DateQuery(FieldQuery[str]): """Matches date fields stored as seconds since Unix epoch time. Dates can be specified as ``year-month-day`` strings where only year is mandatory. The value of a date field can be matched against a date interval by using an ellipsis interval syntax similar to that of NumericQuery. """ def __init__(self, field_name: str, pattern: str, fast: bool = True): super().__init__(field_name, pattern, fast) start, end = _parse_periods(pattern) self.interval = DateInterval.from_periods(start, end) def match(self, obj: Model) -> bool: if self.field_name not in obj: return False timestamp = float(obj[self.field_name]) date = datetime.fromtimestamp(timestamp) return self.interval.contains(date) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: clause_parts = [] subvals = [] # Convert the `datetime` objects to an integer number of seconds since # the (local) Unix epoch using `datetime.timestamp()`. if self.interval.start: clause_parts.append(f"{self.field} >= ?") subvals.append(int(self.interval.start.timestamp())) if self.interval.end: clause_parts.append(f"{self.field} < ?") subvals.append(int(self.interval.end.timestamp())) if clause_parts: # One- or two-sided interval. clause = " AND ".join(clause_parts) else: # Match any date. clause = "1" return clause, subvals class DurationQuery(NumericQuery): """NumericQuery that allow human-friendly (M:SS) time interval formats. Converts the range(s) to a float value, and delegates on NumericQuery. Raises InvalidQueryError when the pattern does not represent an int, float or M:SS time interval. """ def _convert(self, s: str) -> float | None: """Convert a M:SS or numeric string to a float. Return None if `s` is empty. Raise an InvalidQueryError if the string cannot be converted. """ if not s: return None try: return raw_seconds_short(s) except ValueError: try: return float(s) except ValueError: raise InvalidQueryArgumentValueError( s, "a M:SS string or a float" ) class SingletonQuery(FieldQuery[str]): """This query is responsible for the 'singleton' lookup. It is based on the FieldQuery and constructs a SQL clause 'album_id is NULL' which yields the same result as the previous filter in Python but is more performant since it's done in SQL. Using util.str2bool ensures that lookups like singleton:true, singleton:1 and singleton:false, singleton:0 are handled consistently. """ def __new__(cls, field: str, value: str, *args, **kwargs): query = NoneQuery("album_id") if util.str2bool(value): return query return NotQuery(query) # Sorting. class Sort: """An abstract class representing a sort operation for a query into the database. """ def order_clause(self) -> str | None: """Generates a SQL fragment to be used in a ORDER BY clause, or None if no fragment is used (i.e., this is a slow sort). """ return None def sort(self, items: list[AnyModel]) -> list[AnyModel]: """Sort the list of objects and return a list.""" return sorted(items) def is_slow(self) -> bool: """Indicate whether this query is *slow*, meaning that it cannot be executed in SQL and must be executed in Python. """ return False def __hash__(self) -> int: return 0 def __eq__(self, other) -> bool: return type(self) is type(other) def __repr__(self): return f"{self.__class__.__name__}()" class MultipleSort(Sort): """Sort that encapsulates multiple sub-sorts.""" def __init__(self, sorts: list[Sort] | None = None): self.sorts = sorts or [] def add_sort(self, sort: Sort): self.sorts.append(sort) def order_clause(self) -> str: """Return the list SQL clauses for those sub-sorts for which we can be (at least partially) fast. A contiguous suffix of fast (SQL-capable) sub-sorts are executable in SQL. The remaining, even if they are fast independently, must be executed slowly. """ order_strings = [] for sort in reversed(self.sorts): clause = sort.order_clause() if clause is None: break order_strings.append(clause) order_strings.reverse() return ", ".join(order_strings) def is_slow(self) -> bool: for sort in self.sorts: if sort.is_slow(): return True return False def sort(self, items): slow_sorts = [] switch_slow = False for sort in reversed(self.sorts): if switch_slow: slow_sorts.append(sort) elif sort.order_clause() is None: switch_slow = True slow_sorts.append(sort) else: pass for sort in slow_sorts: items = sort.sort(items) return items def __repr__(self): return f"{self.__class__.__name__}({self.sorts!r})" def __hash__(self): return hash(tuple(self.sorts)) def __eq__(self, other): return super().__eq__(other) and self.sorts == other.sorts class FieldSort(Sort): """An abstract sort criterion that orders by a specific field (of any kind). """ def __init__( self, field: str, ascending: bool = True, case_insensitive: bool = True, ): self.field = field self.ascending = ascending self.case_insensitive = case_insensitive def sort(self, objs: list[AnyModel]) -> list[AnyModel]: # TODO: Conversion and null-detection here. In Python 3, # comparisons with None fail. We should also support flexible # attributes with different types without falling over. def key(obj: Model) -> Any: field_val = obj.get(self.field, None) if field_val is None: if _type := obj._types.get(self.field): # If the field is typed, use its null value. field_val = obj._types[self.field].null else: # If not, fall back to using an empty string. field_val = "" if self.case_insensitive and isinstance(field_val, str): field_val = field_val.lower() return field_val return sorted(objs, key=key, reverse=not self.ascending) def __repr__(self) -> str: return ( f"{self.__class__.__name__}" f"({self.field!r}, ascending={self.ascending!r})" ) def __hash__(self) -> int: return hash((self.field, self.ascending)) def __eq__(self, other) -> bool: return ( super().__eq__(other) and self.field == other.field and self.ascending == other.ascending ) class FixedFieldSort(FieldSort): """Sort object to sort on a fixed field.""" def order_clause(self) -> str: order = "ASC" if self.ascending else "DESC" if self.case_insensitive: field = ( "(CASE " f"WHEN TYPEOF({self.field})='text' THEN LOWER({self.field}) " f"WHEN TYPEOF({self.field})='blob' THEN LOWER({self.field}) " f"ELSE {self.field} END)" ) else: field = self.field return f"{field} {order}" class SlowFieldSort(FieldSort): """A sort criterion by some model field other than a fixed field: i.e., a computed or flexible field. """ def is_slow(self) -> bool: return True class NullSort(Sort): """No sorting. Leave results unsorted.""" def sort(self, items: list[AnyModel]) -> list[AnyModel]: return items def __nonzero__(self) -> bool: return self.__bool__() def __bool__(self) -> bool: return False def __eq__(self, other) -> bool: return type(self) is type(other) or other is None def __hash__(self) -> int: return 0 class SmartArtistSort(FieldSort): """Sort by artist (either album artist or track artist), prioritizing the sort field over the raw field. """ def order_clause(self): order = "ASC" if self.ascending else "DESC" collate = "COLLATE NOCASE" if self.case_insensitive else "" field = self.field return f"COALESCE(NULLIF({field}_sort, ''), {field}) {collate} {order}" def sort(self, objs: list[AnyModel]) -> list[AnyModel]: def key(o): val = o[f"{self.field}_sort"] or o[self.field] return val.lower() if self.case_insensitive else val return sorted(objs, key=key, reverse=not self.ascending) ================================================ FILE: beets/dbcore/queryparse.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Parsing of strings into DBCore queries.""" from __future__ import annotations import itertools import re from typing import TYPE_CHECKING from . import query if TYPE_CHECKING: from collections.abc import Collection, Sequence from ..library import LibModel from .query import FieldQueryType, Sort Prefixes = dict[str, FieldQueryType] PARSE_QUERY_PART_REGEX = re.compile( # Non-capturing optional segment for the keyword. r"(-|\^)?" # Negation prefixes. r"(?:" r"(\S+?)" # The field key. r"(? tuple[str | None, str, FieldQueryType, bool]: """Parse a single *query part*, which is a chunk of a complete query string representing a single criterion. A query part is a string consisting of: - A *pattern*: the value to look for. - Optionally, a *field name* preceding the pattern, separated by a colon. So in `foo:bar`, `foo` is the field name and `bar` is the pattern. - Optionally, a *query prefix* just before the pattern (and after the optional colon) indicating the type of query that should be used. For example, in `~foo`, `~` might be a prefix. (The set of prefixes to look for is given in the `prefixes` parameter.) - Optionally, a negation indicator, `-` or `^`, at the very beginning. Both prefixes and the separating `:` character may be escaped with a backslash to avoid their normal meaning. The function returns a tuple consisting of: - The field name: a string or None if it's not present. - The pattern, a string. - The query class to use, which inherits from the base :class:`Query` type. - A negation flag, a bool. The three optional parameters determine which query class is used (i.e., the third return value). They are: - `query_classes`, which maps field names to query classes. These are used when no explicit prefix is present. - `prefixes`, which maps prefix strings to query classes. - `default_class`, the fallback when neither the field nor a prefix indicates a query class. So the precedence for determining which query class to return is: prefix, followed by field, and finally the default. For example, assuming the `:` prefix is used for `RegexpQuery`: - `'stapler'` -> `(None, 'stapler', SubstringQuery, False)` - `'color:red'` -> `('color', 'red', SubstringQuery, False)` - `':^Quiet'` -> `(None, '^Quiet', RegexpQuery, False)`, because the `^` follows the `:` - `'color::b..e'` -> `('color', 'b..e', RegexpQuery, False)` - `'-color:red'` -> `('color', 'red', SubstringQuery, True)` """ # Apply the regular expression and extract the components. part = part.strip() match = PARSE_QUERY_PART_REGEX.match(part) assert match # Regex should always match negate = bool(match.group(1)) key = match.group(2) term = match.group(3).replace("\\:", ":") # Check whether there's a prefix in the query and use the # corresponding query type. for pre, query_class in prefixes.items(): if term.startswith(pre): return key, term[len(pre) :], query_class, negate # No matching prefix, so use either the query class determined by # the field or the default as a fallback. query_class = query_classes.get(key, default_class) return key, term, query_class, negate def construct_query_part( model_cls: type[LibModel], prefixes: Prefixes, query_part: str, ) -> query.Query: """Parse a *query part* string and return a :class:`Query` object. :param model_cls: The :class:`Model` class that this is a query for. This is used to determine the appropriate query types for the model's fields. :param prefixes: A map from prefix strings to :class:`Query` types. :param query_part: The string to parse. See the documentation for `parse_query_part` for more information on query part syntax. """ # A shortcut for empty query parts. if not query_part: return query.TrueQuery() out_query: query.Query # Use `model_cls` to build up a map from field (or query) names to # `Query` classes. query_classes: dict[str, FieldQueryType] = {} for k, t in itertools.chain( model_cls._fields.items(), model_cls._types.items() ): query_classes[k] = t.query query_classes.update(model_cls._queries) # Non-field queries. # Parse the string. key, pattern, query_class, negate = parse_query_part( query_part, query_classes, prefixes ) if key is None: # If there's no key (field name) specified, this is a "match anything" # query. out_query = model_cls.any_field_query(pattern, query_class) else: # Field queries get constructed according to the name of the field # they are querying. out_query = model_cls.field_query(key.lower(), pattern, query_class) # Apply negation. if negate: return query.NotQuery(out_query) else: return out_query # TYPING ERROR def query_from_strings( query_cls: type[query.CollectionQuery], model_cls: type[LibModel], prefixes: Prefixes, query_parts: Collection[str], ) -> query.Query: """Creates a collection query of type `query_cls` from a list of strings in the format used by parse_query_part. `model_cls` determines how queries are constructed from strings. """ subqueries = [] for part in query_parts: subqueries.append(construct_query_part(model_cls, prefixes, part)) if not subqueries: # No terms in query. subqueries = [query.TrueQuery()] return query_cls(subqueries) def construct_sort_part( model_cls: type[LibModel], part: str, case_insensitive: bool = True, ) -> Sort: """Create a `Sort` from a single string criterion. `model_cls` is the `Model` being queried. `part` is a single string ending in ``+`` or ``-`` indicating the sort. `case_insensitive` indicates whether or not the sort should be performed in a case sensitive manner. """ assert part, "part must be a field name and + or -" field = part[:-1] assert field, "field is missing" direction = part[-1] assert direction in ("+", "-"), "part must end with + or -" is_ascending = direction == "+" if sort_cls := model_cls._sorts.get(field): if isinstance(sort_cls, query.SmartArtistSort): field = "albumartist" if model_cls.__name__ == "Album" else "artist" elif field in model_cls._fields: sort_cls = query.FixedFieldSort else: # Flexible or computed. sort_cls = query.SlowFieldSort return sort_cls(field, is_ascending, case_insensitive) def sort_from_strings( model_cls: type[LibModel], sort_parts: Sequence[str], case_insensitive: bool = True, ) -> Sort: """Create a `Sort` from a list of sort criteria (strings).""" if not sort_parts: return query.NullSort() elif len(sort_parts) == 1: return construct_sort_part(model_cls, sort_parts[0], case_insensitive) else: sort = query.MultipleSort() for part in sort_parts: sort.add_sort( construct_sort_part(model_cls, part, case_insensitive) ) return sort def parse_sorted_query( model_cls: type[LibModel], parts: list[str], prefixes: Prefixes = {}, case_insensitive: bool = True, ) -> tuple[query.Query, Sort]: """Given a list of strings, create the `Query` and `Sort` that they represent. """ # Separate query token and sort token. query_parts = [] sort_parts = [] # Split up query in to comma-separated subqueries, each representing # an AndQuery, which need to be joined together in one OrQuery subquery_parts = [] for part in [*parts, ","]: if part.endswith(","): # Ensure we can catch "foo, bar" as well as "foo , bar" last_subquery_part = part[:-1] if last_subquery_part: subquery_parts.append(last_subquery_part) # Parse the subquery in to a single AndQuery # TODO: Avoid needlessly wrapping AndQueries containing 1 subquery? query_parts.append( query_from_strings( query.AndQuery, model_cls, prefixes, subquery_parts ) ) del subquery_parts[:] else: # Sort parts (1) end in + or -, (2) don't have a field, and # (3) consist of more than just the + or -. if part.endswith(("+", "-")) and ":" not in part and len(part) > 1: sort_parts.append(part) else: subquery_parts.append(part) # Avoid needlessly wrapping single statements in an OR q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0] s = sort_from_strings(model_cls, sort_parts, case_insensitive) return q, s ================================================ FILE: beets/dbcore/types.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Representation of type information for DBCore model fields.""" from __future__ import annotations import re import time import typing from abc import ABC from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast import beets from beets import util from beets.util.units import human_seconds_short, raw_seconds_short from . import query SQLiteType = query.SQLiteType BLOB_TYPE = query.BLOB_TYPE MULTI_VALUE_DELIMITER = "\\␀" class ModelType(typing.Protocol): """Protocol that specifies the required constructor for model types, i.e. a function that takes any argument and attempts to parse it to the given type. """ def __init__(self, value: Any = None): ... # Generic type variables, used for the value type T and null type N (if # nullable, else T and N are set to the same type for the concrete subclasses # of Type). if TYPE_CHECKING: N = TypeVar("N", default=Any) T = TypeVar("T", bound=ModelType, default=Any) else: N = TypeVar("N") T = TypeVar("T", bound=ModelType) class Type(ABC, Generic[T, N]): """An object encapsulating the type of a model field. Includes information about how to store, query, format, and parse a given field. """ sql: str = "TEXT" """The SQLite column type for the value. """ query: query.FieldQueryType = query.SubstringQuery """The `Query` subclass to be used when querying the field. """ # For sequence-like types, keep ``model_type`` unsubscripted as it's used # for ``isinstance`` checks. Use ``list`` instead of ``list[str]`` model_type: type[T] """The Python type that is used to represent the value in the model. The model is guaranteed to return a value of this type if the field is accessed. To this end, the constructor is used by the `normalize` and `from_sql` methods and the `default` property. """ @property def null(self) -> N: """The value to be exposed when the underlying value is None.""" # Note that this default implementation only makes sense for T = N. # It would be better to implement `null()` only in subclasses, or # have a field null_type similar to `model_type` and use that here. return cast(N, self.model_type()) def format(self, value: N | T) -> str: """Given a value of this type, produce a Unicode string representing the value. This is used in template evaluation. """ if value is None: value = self.null # `self.null` might be `None` if value is None: return "" elif isinstance(value, bytes): return value.decode("utf-8", "ignore") else: return str(value) def parse(self, string: str) -> T | N: """Parse a (possibly human-written) string and return the indicated value of this type. """ try: return self.model_type(string) except ValueError: return self.null def normalize(self, value: Any) -> T | N: """Given a value that will be assigned into a field of this type, normalize the value to have the appropriate type. This base implementation only reinterprets `None`. """ # TYPING ERROR if value is None: return self.null else: # TODO This should eventually be replaced by # `self.model_type(value)` return cast(T, value) def from_sql(self, sql_value: SQLiteType) -> T | N: """Receives the value stored in the SQL backend and return the value to be stored in the model. For fixed fields the type of `value` is determined by the column type affinity given in the `sql` property and the SQL to Python mapping of the database adapter. For more information see: https://www.sqlite.org/datatype3.html https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types Flexible fields have the type affinity `TEXT`. This means the `sql_value` is either a `memoryview` or a `unicode` object` and the method must handle these in addition. """ if isinstance(sql_value, memoryview): sql_value = bytes(sql_value).decode("utf-8", "ignore") if isinstance(sql_value, str): return self.parse(sql_value) else: return self.normalize(sql_value) def to_sql(self, model_value: Any) -> SQLiteType: """Convert a value as stored in the model object to a value used by the database adapter. """ return model_value # Reusable types. class Default(Type[str, None]): model_type = str @property def null(self): return None class BaseInteger(Type[int, N]): """A basic integer type.""" sql = "INTEGER" query = query.NumericQuery model_type = int def normalize(self, value: Any) -> int | N: try: return self.model_type(round(float(value))) except ValueError: return self.null except TypeError: return self.null class Integer(BaseInteger[int]): @property def null(self) -> int: return 0 class NullInteger(BaseInteger[None]): @property def null(self) -> None: return None class BasePaddedInt(BaseInteger[N]): """An integer field that is formatted with a given number of digits, padded with zeroes. """ def __init__(self, digits: int): self.digits = digits def format(self, value: int | N) -> str: return f"{value or 0:0{self.digits}d}" class PaddedInt(BasePaddedInt[int]): pass class NullPaddedInt(BasePaddedInt[None]): """Same as `PaddedInt`, but does not normalize `None` to `0`.""" @property def null(self) -> None: return None class ScaledInt(Integer): """An integer whose formatting operation scales the number by a constant and adds a suffix. Good for units with large magnitudes. """ def __init__(self, unit: int, suffix: str = ""): self.unit = unit self.suffix = suffix def format(self, value: int) -> str: return f"{(value or 0) // self.unit}{self.suffix}" class Id(NullInteger): """An integer used as the row id or a foreign key in a SQLite table. This type is nullable: None values are not translated to zero. """ @property def null(self) -> None: return None def __init__(self, primary: bool = True): if primary: self.sql = "INTEGER PRIMARY KEY" class BaseFloat(Type[float, N]): """A basic floating-point type. The `digits` parameter specifies how many decimal places to use in the human-readable representation. """ sql = "REAL" query: query.FieldQueryType = query.NumericQuery model_type = float def __init__(self, digits: int = 1): self.digits = digits def format(self, value: float | N) -> str: return f"{value or 0:.{self.digits}f}" class Float(BaseFloat[float]): """Floating-point type that normalizes `None` to `0.0`.""" @property def null(self) -> float: return 0.0 class NullFloat(BaseFloat[None]): """Same as `Float`, but does not normalize `None` to `0.0`.""" @property def null(self) -> None: return None class BaseString(Type[T, N]): """A Unicode string type.""" sql = "TEXT" query = query.SubstringQuery def normalize(self, value: Any) -> T | N: if value is None: return self.null else: return self.model_type(value) class String(BaseString[str, Any]): """A Unicode string type.""" model_type = str class DelimitedString(BaseString[list, list]): # type: ignore[type-arg] r"""A list of Unicode strings, represented in-database by a single string containing delimiter-separated values. In template evaluation the list is formatted by joining the values with a fixed '; ' delimiter regardless of the database delimiter. That is because the '\␀' character used for multi-value fields is mishandled on Windows as it contains a backslash character. """ model_type = list fmt_delimiter = "; " def __init__(self, db_delimiter: str): self.db_delimiter = db_delimiter def format(self, value: list[str]): return self.fmt_delimiter.join(value) def parse(self, string: str): if not string: return [] delimiter = ( self.db_delimiter if self.db_delimiter in string else self.fmt_delimiter ) return string.split(delimiter) def to_sql(self, model_value: list[str]): return self.db_delimiter.join(model_value) class Boolean(Type): """A boolean type.""" sql = "INTEGER" query = query.BooleanQuery model_type = bool def format(self, value: bool) -> str: return str(bool(value)) def parse(self, string: str) -> bool: return util.str2bool(string) class DateType(Float): # TODO representation should be `datetime` object # TODO distinguish between date and time types query = query.DateQuery def format(self, value): return time.strftime( beets.config["time_format"].as_str(), time.localtime(value or 0) ) def parse(self, string): try: # Try a formatted date string. return time.mktime( time.strptime(string, beets.config["time_format"].as_str()) ) except ValueError: # Fall back to a plain timestamp number. try: return float(string) except ValueError: return self.null class BasePathType(Type[bytes, N]): """A dbcore type for filesystem paths. These are represented as `bytes` objects, in keeping with the Unix filesystem abstraction. """ sql = "BLOB" query = query.PathQuery model_type = bytes def parse(self, string: str) -> bytes: return util.normpath(string) def normalize(self, value: Any) -> bytes | N: if isinstance(value, str): # Paths stored internally as encoded bytes. return util.bytestring_path(value) elif isinstance(value, BLOB_TYPE): # We unwrap buffers to bytes. return bytes(value) else: return value def from_sql(self, sql_value): return self.normalize(sql_value) def to_sql(self, value: bytes) -> BLOB_TYPE: if isinstance(value, bytes): value = BLOB_TYPE(value) return value class NullPathType(BasePathType[None]): @property def null(self) -> None: return None def format(self, value: bytes | None) -> str: return util.displayable_path(value or b"") class PathType(BasePathType[bytes]): @property def null(self) -> bytes: return b"" def format(self, value: bytes) -> str: return util.displayable_path(value or b"") class MusicalKey(String): """String representing the musical key of a song. The standard format is C, Cm, C#, C#m, etc. """ ENHARMONIC: ClassVar[dict[str, str]] = { r"db": "c#", r"eb": "d#", r"gb": "f#", r"ab": "g#", r"bb": "a#", } null = None def parse(self, key): key = key.lower() for flat, sharp in self.ENHARMONIC.items(): key = re.sub(flat, sharp, key) key = re.sub(r"[\W\s]+minor", "m", key) key = re.sub(r"[\W\s]+major", "", key) return key.capitalize() def normalize(self, key): if key is None: return None else: return self.parse(key) class DurationType(Float): """Human-friendly (M:SS) representation of a time interval.""" query = query.DurationQuery def format(self, value): if not beets.config["format_raw_length"].get(bool): return human_seconds_short(value or 0.0) else: return value def parse(self, string): try: # Try to format back hh:ss to seconds. return raw_seconds_short(string) except ValueError: # Fall back to a plain float. try: return float(string) except ValueError: return self.null # Shared instances of common types. DEFAULT = Default() INTEGER = Integer() PRIMARY_ID = Id(True) FOREIGN_ID = Id(False) FLOAT = Float() NULL_FLOAT = NullFloat() STRING = String() BOOLEAN = Boolean() DATE = DateType() SEMICOLON_SPACE_DSV = DelimitedString("; ") # Will set the proper null char in mediafile MULTI_VALUE_DSV = DelimitedString(MULTI_VALUE_DELIMITER) ================================================ FILE: beets/importer/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides the basic, interface-agnostic workflow for importing and autotagging music files. """ from .session import ImportAbortError, ImportSession from .tasks import ( Action, ArchiveImportTask, ImportTask, SentinelImportTask, SingletonImportTask, ) # Note: Stages are not exposed to the public API __all__ = [ "Action", "ArchiveImportTask", "ImportAbortError", "ImportSession", "ImportTask", "SentinelImportTask", "SingletonImportTask", ] ================================================ FILE: beets/importer/session.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import os import time from typing import TYPE_CHECKING from beets import config, logging, plugins, util from beets.importer.tasks import Action from beets.util import displayable_path, normpath, pipeline, syspath from . import stages as stagefuncs from .state import ImportState if TYPE_CHECKING: from collections.abc import Sequence from beets import dbcore, library from beets.util import PathBytes from .tasks import ImportTask QUEUE_SIZE = 128 # Global logger. log = logging.getLogger("beets") class ImportAbortError(Exception): """Raised when the user aborts the tagging operation.""" pass class ImportSession: """Controls an import action. Subclasses should implement methods to communicate with the user or otherwise make decisions. """ logger: logging.Logger paths: list[PathBytes] lib: library.Library _is_resuming: dict[bytes, bool] _merged_items: set[PathBytes] _merged_dirs: set[PathBytes] def __init__( self, lib: library.Library, loghandler: logging.Handler | None, paths: Sequence[PathBytes] | None, query: dbcore.Query | None, ): """Create a session. Parameters ---------- lib : library.Library The library instance to which items will be imported. loghandler : logging.Handler or None A logging handler to use for the session's logger. If None, a NullHandler will be used. paths : os.PathLike or None The paths to be imported. query : dbcore.Query or None A query to filter items for import. """ self.lib = lib self.logger = self._setup_logging(loghandler) self.query = query self._is_resuming = {} self._merged_items = set() self._merged_dirs = set() # Normalize the paths. self.paths = list(map(normpath, paths or [])) def _setup_logging(self, loghandler: logging.Handler | None): logger = logging.getLogger(__name__) logger.propagate = False if not loghandler: loghandler = logging.NullHandler() logger.handlers = [loghandler] return logger def set_config(self, config): """Set `config` property from global import config and make implied changes. """ # FIXME: Maybe this function should not exist and should instead # provide "decision wrappers" like "should_resume()", etc. iconfig = dict(config) self.config = iconfig # Incremental and progress are mutually exclusive. if iconfig["incremental"]: iconfig["resume"] = False # When based on a query instead of directories, never # save progress or try to resume. if self.query is not None: iconfig["resume"] = False iconfig["incremental"] = False if iconfig["reflink"]: iconfig["reflink"] = iconfig["reflink"].as_choice( ["auto", True, False] ) # Copy, move, reflink, link, and hardlink are mutually exclusive. if iconfig["move"]: iconfig["copy"] = False iconfig["link"] = False iconfig["hardlink"] = False iconfig["reflink"] = False elif iconfig["link"]: iconfig["copy"] = False iconfig["move"] = False iconfig["hardlink"] = False iconfig["reflink"] = False elif iconfig["hardlink"]: iconfig["copy"] = False iconfig["move"] = False iconfig["link"] = False iconfig["reflink"] = False elif iconfig["reflink"]: iconfig["copy"] = False iconfig["move"] = False iconfig["link"] = False iconfig["hardlink"] = False # Only delete when copying. if not iconfig["copy"]: iconfig["delete"] = False self.want_resume = config["resume"].as_choice([True, False, "ask"]) def tag_log(self, status, paths: Sequence[PathBytes]): """Log a message about a given album to the importer log. The status should reflect the reason the album couldn't be tagged. """ self.logger.info("{} {}", status, displayable_path(paths)) def log_choice(self, task: ImportTask, duplicate=False): """Logs the task's current choice if it should be logged. If ``duplicate``, then this is a secondary choice after a duplicate was detected and a decision was made. """ paths = task.paths if duplicate: # Duplicate: log all three choices (skip, keep both, and trump). if task.should_remove_duplicates: self.tag_log("duplicate-replace", paths) elif task.choice_flag in (Action.ASIS, Action.APPLY): self.tag_log("duplicate-keep", paths) elif task.choice_flag is Action.SKIP: self.tag_log("duplicate-skip", paths) else: # Non-duplicate: log "skip" and "asis" choices. if task.choice_flag is Action.ASIS: self.tag_log("asis", paths) elif task.choice_flag is Action.SKIP: self.tag_log("skip", paths) def should_resume(self, path: PathBytes): raise NotImplementedError def choose_match(self, task: ImportTask): raise NotImplementedError def resolve_duplicate(self, task: ImportTask, found_duplicates): raise NotImplementedError def choose_item(self, task: ImportTask): raise NotImplementedError def run(self): """Run the import task.""" self.logger.info("import started {}", time.asctime()) self.set_config(config["import"]) # Set up the pipeline. if self.query is None: stages = [stagefuncs.read_tasks(self)] else: stages = [stagefuncs.query_tasks(self)] # In pretend mode, just log what would otherwise be imported. if self.config["pretend"]: stages += [stagefuncs.log_files(self)] else: if self.config["group_albums"] and not self.config["singletons"]: # Split directory tasks into one task for each album. stages += [stagefuncs.group_albums(self)] # These stages either talk to the user to get a decision or, # in the case of a non-autotagged import, just choose to # import everything as-is. In *both* cases, these stages # also add the music to the library database, so later # stages need to read and write data from there. if self.config["autotag"]: stages += [ stagefuncs.lookup_candidates(self), stagefuncs.user_query(self), ] else: stages += [stagefuncs.import_asis(self)] # Plugin stages. for stage_func in plugins.early_import_stages(): stages.append(stagefuncs.plugin_stage(self, stage_func)) for stage_func in plugins.import_stages(): stages.append(stagefuncs.plugin_stage(self, stage_func)) stages += [stagefuncs.manipulate_files(self)] pl = pipeline.Pipeline(stages) # Run the pipeline. plugins.send("import_begin", session=self) try: if config["threaded"]: pl.run_parallel(QUEUE_SIZE) else: pl.run_sequential() except ImportAbortError: # User aborted operation. Silently stop. pass # Incremental and resumed imports def already_imported(self, toppath: PathBytes, paths: Sequence[PathBytes]): """Returns true if the files belonging to this task have already been imported in a previous session. """ if self.is_resuming(toppath) and all( [ImportState().progress_has_element(toppath, p) for p in paths] ): return True if self.config["incremental"] and tuple(paths) in self.history_dirs: return True return False _history_dirs = None @property def history_dirs(self) -> set[tuple[PathBytes, ...]]: # FIXME: This could be simplified to a cached property if self._history_dirs is None: self._history_dirs = ImportState().taghistory return self._history_dirs def already_merged(self, paths: Sequence[PathBytes]): """Returns true if all the paths being imported were part of a merge during previous tasks. """ for path in paths: if path not in self._merged_items and path not in self._merged_dirs: return False return True def mark_merged(self, paths: Sequence[PathBytes]): """Mark paths and directories as merged for future reimport tasks.""" self._merged_items.update(paths) dirs = { os.path.dirname(path) if os.path.isfile(syspath(path)) else path for path in paths } self._merged_dirs.update(dirs) def is_resuming(self, toppath: PathBytes): """Return `True` if user wants to resume import of this path. You have to call `ask_resume` first to determine the return value. """ return self._is_resuming.get(toppath, False) def ask_resume(self, toppath: PathBytes): """If import of `toppath` was aborted in an earlier session, ask user if they want to resume the import. Determines the return value of `is_resuming(toppath)`. """ if self.want_resume and ImportState().progress_has(toppath): # Either accept immediately or prompt for input to decide. if self.want_resume is True or self.should_resume(toppath): log.warning( "Resuming interrupted import of {}", util.displayable_path(toppath), ) self._is_resuming[toppath] = True else: # Clear progress; we're starting from the top. ImportState().progress_reset(toppath) ================================================ FILE: beets/importer/stages.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import itertools import logging from typing import TYPE_CHECKING from beets import config, plugins from beets.util import MoveOperation, displayable_path, pipeline from .tasks import ( Action, ImportTask, ImportTaskFactory, SentinelImportTask, SingletonImportTask, ) if TYPE_CHECKING: from collections.abc import Callable from beets import library from .session import ImportSession # Global logger. log = logging.getLogger("beets") # ---------------------------- Producer functions ---------------------------- # # Functions that are called first i.e. they generate import tasks def read_tasks(session: ImportSession): """A generator yielding all the albums (as ImportTask objects) found in the user-specified list of paths. In the case of a singleton import, yields single-item tasks instead. """ skipped = 0 for toppath in session.paths: # Check whether we need to resume the import. session.ask_resume(toppath) # Generate tasks. task_factory = ImportTaskFactory(toppath, session) yield from task_factory.tasks() skipped += task_factory.skipped if not task_factory.imported: log.warning("No files imported from {}", displayable_path(toppath)) # Show skipped directories (due to incremental/resume). if skipped: log.info("Skipped {} paths.", skipped) def query_tasks(session: ImportSession): """A generator that works as a drop-in-replacement for read_tasks. Instead of finding files from the filesystem, a query is used to match items from the library. """ task: ImportTask if session.config["singletons"]: # Search for items. for item in session.lib.items(session.query): task = SingletonImportTask(None, item) for task in task.handle_created(session): yield task else: # Search for albums. for album in session.lib.albums(session.query): log.debug( "yielding album {0.id}: {0.albumartist} - {0.album}", album ) items = list(album.items()) _freshen_items(items) task = ImportTask(None, [album.item_dir()], items) for task in task.handle_created(session): yield task # ---------------------------------- Stages ---------------------------------- # # Functions that process import tasks, may transform or filter them # They are chained together in the pipeline e.g. stage2(stage1(task)) -> task def group_albums(session: ImportSession): """A pipeline stage that groups the items of each task into albums using their metadata. Groups are identified using their artist and album fields. The pipeline stage emits new album tasks for each discovered group. """ def group(item): return (item.albumartist or item.artist, item.album) task = None while True: task = yield task if task.skip: continue tasks = [] sorted_items: list[library.Item] = sorted(task.items, key=group) for _, items in itertools.groupby(sorted_items, group): l_items = list(items) task = ImportTask(task.toppath, [i.path for i in l_items], l_items) tasks += task.handle_created(session) tasks.append(SentinelImportTask(task.toppath, task.paths)) task = pipeline.multiple(tasks) @pipeline.mutator_stage def lookup_candidates(session: ImportSession, task: ImportTask): """A coroutine for performing the initial MusicBrainz lookup for an album. It accepts lists of Items and yields (items, cur_artist, cur_album, candidates, rec) tuples. If no match is found, all of the yielded parameters (except items) are None. """ if task.skip: # FIXME This gets duplicated a lot. We need a better # abstraction. return plugins.send("import_task_start", session=session, task=task) log.debug("Looking up: {}", displayable_path(task.paths)) # Restrict the initial lookup to IDs specified by the user via the -m # option. Currently all the IDs are passed onto the tasks directly. task.lookup_candidates(session.config["search_ids"].as_str_seq()) @pipeline.stage def user_query(session: ImportSession, task: ImportTask): """A coroutine for interfacing with the user about the tagging process. The coroutine accepts an ImportTask objects. It uses the session's `choose_match` method to determine the `action` for this task. Depending on the action additional stages are executed and the processed task is yielded. It emits the ``import_task_choice`` event for plugins. Plugins have access to the choice via the ``task.choice_flag`` property and may choose to change it. """ if task.skip: return task if session.already_merged(task.paths): return pipeline.BUBBLE # Ask the user for a choice. task.choose_match(session) plugins.send("import_task_choice", session=session, task=task) # As-tracks: transition to singleton workflow. if task.choice_flag is Action.TRACKS: # Set up a little pipeline for dealing with the singletons. def emitter(task): for item in task.items: task = SingletonImportTask(task.toppath, item) yield from task.handle_created(session) yield SentinelImportTask(task.toppath, task.paths) return _extend_pipeline( emitter(task), lookup_candidates(session), user_query(session) ) # As albums: group items by albums and create task for each album if task.choice_flag is Action.ALBUMS: return _extend_pipeline( [task], group_albums(session), lookup_candidates(session), user_query(session), ) _resolve_duplicates(session, task) if task.should_merge_duplicates: # Create a new task for tagging the current items # and duplicates together duplicate_items = task.duplicate_items(session.lib) # Duplicates would be reimported so make them look "fresh" _freshen_items(duplicate_items) duplicate_paths = [item.path for item in duplicate_items] # Record merged paths in the session so they are not reimported session.mark_merged(duplicate_paths) merged_task = ImportTask( None, task.paths + duplicate_paths, task.items + duplicate_items ) return _extend_pipeline( [merged_task], lookup_candidates(session), user_query(session) ) _apply_choice(session, task) return task @pipeline.mutator_stage def import_asis(session: ImportSession, task: ImportTask): """Select the `action.ASIS` choice for all tasks. This stage replaces the initial_lookup and user_query stages when the importer is run without autotagging. """ if task.skip: return log.info("{}", displayable_path(task.paths)) task.set_choice(Action.ASIS) _resolve_duplicates(session, task) _apply_choice(session, task) @pipeline.mutator_stage def plugin_stage( session: ImportSession, func: Callable[[ImportSession, ImportTask], None], task: ImportTask, ): """A coroutine (pipeline stage) that calls the given function with each non-skipped import task. These stages occur between applying metadata changes and moving/copying/writing files. """ if task.skip: return func(session, task) # Stage may modify DB, so re-load cached item data. # FIXME Importer plugins should not modify the database but instead # the albums and items attached to tasks. task.reload() @pipeline.stage def log_files(session: ImportSession, task: ImportTask): """A coroutine (pipeline stage) to log each file to be imported.""" if isinstance(task, SingletonImportTask): log.info("Singleton: {}", displayable_path(task.item["path"])) elif task.items: log.info("Album: {}", displayable_path(task.paths[0])) for item in task.items: log.info(" {}", displayable_path(item["path"])) # --------------------------------- Consumer --------------------------------- # # Anything that should be placed last in the pipeline # In theory every stage could be a consumer, but in practice there are some # functions which are typically placed last in the pipeline @pipeline.stage def manipulate_files(session: ImportSession, task: ImportTask): """A coroutine (pipeline stage) that performs necessary file manipulations *after* items have been added to the library and finalizes each task. """ if not task.skip: if task.should_remove_duplicates: task.remove_duplicates(session.lib) if session.config["move"]: operation = MoveOperation.MOVE elif session.config["copy"]: operation = MoveOperation.COPY elif session.config["link"]: operation = MoveOperation.LINK elif session.config["hardlink"]: operation = MoveOperation.HARDLINK elif session.config["reflink"] == "auto": operation = MoveOperation.REFLINK_AUTO elif session.config["reflink"]: operation = MoveOperation.REFLINK else: operation = None task.manipulate_files( session=session, operation=operation, write=session.config["write"], ) # Progress, cleanup, and event. task.finalize(session) # ---------------------------- Utility functions ----------------------------- # # Private functions only used in the stages above def _apply_choice(session: ImportSession, task: ImportTask): """Apply the task's choice to the Album or Item it contains and add it to the library. """ if task.skip: return # Change metadata. if task.apply: task.apply_metadata() plugins.send("import_task_apply", session=session, task=task) task.add(session.lib) # If ``set_fields`` is set, set those fields to the # configured values. # NOTE: This cannot be done before the ``task.add()`` call above, # because then the ``ImportTask`` won't have an `album` for which # it can set the fields. if config["import"]["set_fields"]: task.set_fields(session.lib) def _resolve_duplicates(session: ImportSession, task: ImportTask): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. """ if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: log.debug("found duplicates: {}", [o.id for o in found_duplicates]) # Get the default action to follow from config. duplicate_action = config["import"]["duplicate_action"].as_choice( { "skip": "s", "keep": "k", "remove": "r", "merge": "m", "ask": "a", } ) log.debug("default action for duplicates: {}", duplicate_action) if duplicate_action == "s": # Skip new. task.set_choice(Action.SKIP) elif duplicate_action == "k": # Keep both. Do nothing; leave the choice intact. pass elif duplicate_action == "r": # Remove old. task.should_remove_duplicates = True elif duplicate_action == "m": # Merge duplicates together task.should_merge_duplicates = True else: # No default action set; ask the session. session.resolve_duplicate(task, found_duplicates) session.log_choice(task, True) def _freshen_items(items): # Clear IDs from re-tagged items so they appear "fresh" when # we add them back to the library. for item in items: item.id = None item.album_id = None def _extend_pipeline(tasks, *stages): # Return pipeline extension for stages with list of tasks if isinstance(tasks, list): task_iter = iter(tasks) else: task_iter = tasks ipl = pipeline.Pipeline([task_iter, *list(stages)]) return pipeline.multiple(ipl.pull()) ================================================ FILE: beets/importer/state.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import logging import os import pickle from bisect import bisect_left, insort from dataclasses import dataclass from typing import TYPE_CHECKING from beets import config if TYPE_CHECKING: from beets.util import PathBytes # Global logger. log = logging.getLogger("beets") @dataclass class ImportState: """Representing the progress of an import task. Opens the state file on creation of the class. If you want to ensure the state is written to disk, you should use the context manager protocol. Tagprogress allows long tagging tasks to be resumed when they pause. Taghistory is a utility for manipulating the "incremental" import log. This keeps track of all directories that were ever imported, which allows the importer to only import new stuff. Usage ----- ``` # Readonly progress = ImportState().tagprogress # Read and write with ImportState() as state: state["key"] = "value" ``` """ tagprogress: dict[PathBytes, list[PathBytes]] taghistory: set[tuple[PathBytes, ...]] path: PathBytes def __init__(self, readonly=False, path: PathBytes | None = None): self.path = path or os.fsencode(config["statefile"].as_filename()) self.tagprogress = {} self.taghistory = set() self._open() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self._save() def _open( self, ): try: with open(self.path, "rb") as f: state = pickle.load(f) # Read the states self.tagprogress = state.get("tagprogress", {}) self.taghistory = state.get("taghistory", set()) except Exception as exc: # The `pickle` module can emit all sorts of exceptions during # unpickling, including ImportError. We use a catch-all # exception to avoid enumerating them all (the docs don't even have a # full list!). log.debug("state file could not be read: {}", exc) def _save(self): try: with open(self.path, "wb") as f: pickle.dump( { "tagprogress": self.tagprogress, "taghistory": self.taghistory, }, f, ) except OSError as exc: log.error("state file could not be written: {}", exc) # -------------------------------- Tagprogress ------------------------------- # def progress_add(self, toppath: PathBytes, *paths: PathBytes): """Record that the files under all of the `paths` have been imported under `toppath`. """ with self as state: imported = state.tagprogress.setdefault(toppath, []) for path in paths: if imported and imported[-1] <= path: imported.append(path) else: insort(imported, path) def progress_has_element(self, toppath: PathBytes, path: PathBytes) -> bool: """Return whether `path` has been imported in `toppath`.""" imported = self.tagprogress.get(toppath, []) i = bisect_left(imported, path) return i != len(imported) and imported[i] == path def progress_has(self, toppath: PathBytes) -> bool: """Return `True` if there exist paths that have already been imported under `toppath`. """ return toppath in self.tagprogress def progress_reset(self, toppath: PathBytes | None): """Reset the progress for `toppath`.""" with self as state: if toppath in state.tagprogress: del state.tagprogress[toppath] # -------------------------------- Taghistory -------------------------------- # def history_add(self, paths: list[PathBytes]): """Add the paths to the history.""" with self as state: state.taghistory.add(tuple(paths)) ================================================ FILE: beets/importer/tasks.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import logging import os import re import shutil import time from collections import defaultdict from collections.abc import Callable from enum import Enum from tempfile import mkdtemp from typing import TYPE_CHECKING, Any import mediafile from beets import autotag, config, library, plugins, util from beets.dbcore.query import PathQuery from .state import ImportState if TYPE_CHECKING: from collections.abc import Iterable, Sequence from beets.autotag.match import Recommendation from .session import ImportSession # Global logger. log = logging.getLogger("beets") SINGLE_ARTIST_THRESH = 0.25 # Usually flexible attributes are preserved (i.e., not updated) during # reimports. The following two lists (globally) change this behaviour for # certain fields. To alter these lists only when a specific plugin is in use, # something like this can be used within that plugin's code: # # from beets import importer # def extend_reimport_fresh_fields_item(): # importer.REIMPORT_FRESH_FIELDS_ITEM.extend(['tidal_track_popularity'] # ) REIMPORT_FRESH_FIELDS_ITEM = [ "data_source", "bandcamp_album_id", "spotify_album_id", "deezer_album_id", "beatport_album_id", "tidal_album_id", "data_url", ] REIMPORT_FRESH_FIELDS_ALBUM = [*REIMPORT_FRESH_FIELDS_ITEM, "media"] # Global logger. log = logging.getLogger("beets") class ImportAbortError(Exception): """Raised when the user aborts the tagging operation.""" pass class Action(Enum): """Enumeration of possible actions for an import task.""" SKIP = "SKIP" ASIS = "ASIS" TRACKS = "TRACKS" APPLY = "APPLY" ALBUMS = "ALBUMS" RETAG = "RETAG" # The RETAG action represents "don't apply any match, but do record # new metadata". It's not reachable via the standard command prompt but # can be used by plugins. class BaseImportTask: """An abstract base class for importer tasks. Tasks flow through the importer pipeline. Each stage can update them.""" toppath: util.PathBytes | None paths: list[util.PathBytes] items: list[library.Item] def __init__( self, toppath: util.PathBytes | None, paths: Iterable[util.PathBytes] | None, items: Iterable[library.Item] | None, ): """Create a task. The primary fields that define a task are: * `toppath`: The user-specified base directory that contains the music for this task. If the task has *no* user-specified base (for example, when importing based on an -L query), this can be None. This is used for tracking progress and history. * `paths`: A list of *specific* paths where the music for this task came from. These paths can be directories, when their entire contents are being imported, or files, when the task comprises individual tracks. This is used for progress/history tracking and for displaying the task to the user. * `items`: A list of `Item` objects representing the music being imported. These fields should not change after initialization. """ self.toppath = toppath self.paths = list(paths) if paths is not None else [] self.items = list(items) if items is not None else [] class ImportTask(BaseImportTask): """Represents a single set of items to be imported along with its intermediate state. May represent an album or a single item. The import session and stages call the following methods in the given order. * `lookup_candidates()` Sets the `common_artist`, `common_album`, `candidates`, and `rec` attributes. `candidates` is a list of `AlbumMatch` objects. * `choose_match()` Uses the session to set the `match` attribute from the `candidates` list. * `find_duplicates()` Returns a list of albums from `lib` with the same artist and album name as the task. * `apply_metadata()` Sets the attributes of the items from the task's `match` attribute. * `add()` Add the imported items and album to the database. * `manipulate_files()` Copy, move, and write files depending on the session configuration. * `set_fields()` Sets the fields given at CLI or configuration to the specified values. * `finalize()` Update the import progress and cleanup the file system. """ choice_flag: Action | None = None match: autotag.AlbumMatch | autotag.TrackMatch | None = None # Keep track of the current task item cur_album: str | None = None cur_artist: str | None = None candidates: Sequence[autotag.AlbumMatch | autotag.TrackMatch] = [] rec: Recommendation | None = None def __init__( self, toppath: util.PathBytes | None, paths: Iterable[util.PathBytes] | None, items: Iterable[library.Item] | None, ): super().__init__(toppath, paths, items) self.should_remove_duplicates = False self.should_merge_duplicates = False self.is_album = True def set_choice( self, choice: Action | autotag.AlbumMatch | autotag.TrackMatch ): """Given an AlbumMatch or TrackMatch object or an action constant, indicates that an action has been selected for this task. Album and trackmatch are implemented as tuples, so we can't use isinstance to check for them. """ # Not part of the task structure: assert choice != Action.APPLY # Only used internally. if choice in ( Action.SKIP, Action.ASIS, Action.TRACKS, Action.ALBUMS, Action.RETAG, ): # TODO: redesign to stricten the type self.choice_flag = choice # type: ignore[assignment] self.match = None else: self.choice_flag = Action.APPLY # Implicit choice. self.match = choice # type: ignore[assignment] def save_progress(self): """Updates the progress state to indicate that this album has finished. """ if self.toppath: ImportState().progress_add(self.toppath, *self.paths) def save_history(self): """Save the directory in the history for incremental imports.""" ImportState().history_add(self.paths) # Logical decisions. @property def apply(self): return self.choice_flag == Action.APPLY @property def skip(self): return self.choice_flag == Action.SKIP # Convenient data. def chosen_info(self): """Return a dictionary of metadata about the current choice. May only be called when the choice flag is ASIS or RETAG (in which case the data comes from the files' current metadata) or APPLY (in which case the data comes from the choice). """ if self.choice_flag in (Action.ASIS, Action.RETAG): likelies, _ = util.get_most_common_tags(self.items) return likelies elif self.choice_flag is Action.APPLY and self.match: return self.match.info.copy() assert False def imported_items(self): """Return a list of Items that should be added to the library. If the tasks applies an album match the method only returns the matched items. """ if self.choice_flag in (Action.ASIS, Action.RETAG): return self.items elif self.choice_flag == Action.APPLY and isinstance( self.match, autotag.AlbumMatch ): return self.match.items else: return [] def apply_metadata(self): """Copy metadata from match info to the items.""" if config["import"]["from_scratch"]: for item in self.match.items: item.clear() autotag.apply_metadata(self.match.info, self.match.item_info_pairs) def duplicate_items(self, lib: library.Library): duplicate_items = [] for album in self.find_duplicates(lib): duplicate_items += album.items() return duplicate_items def remove_duplicates(self, lib: library.Library): duplicate_items = self.duplicate_items(lib) log.debug("removing {} old duplicated items", len(duplicate_items)) for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): log.debug("deleting duplicate {.filepath}", item) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) def set_fields(self, lib: library.Library): """Sets the fields given at CLI or configuration to the specified values, for both the album and all its items. """ items = self.imported_items() for field, view in config["import"]["set_fields"].items(): value = str(view.get()) log.debug( "Set field {}={} for {}", field, value, util.displayable_path(self.paths), ) self.album.set_parse(field, format(self.album, value)) for item in items: item.set_parse(field, format(item, value)) with lib.transaction(): for item in items: item.store() self.album.store() def finalize(self, session: ImportSession): """Save progress, clean up files, and emit plugin event.""" # Update progress. if session.want_resume: self.save_progress() if session.config["incremental"] and not ( # Should we skip recording to incremental list? self.skip and session.config["incremental_skip_later"] ): self.save_history() self.cleanup( copy=session.config["copy"], delete=session.config["delete"], move=session.config["move"], ) if not self.skip: self._emit_imported(session.lib) def cleanup(self, copy=False, delete=False, move=False): """Remove and prune imported paths.""" # Do not delete any files or prune directories when skipping. if self.skip: return items = self.imported_items() # When copying and deleting originals, delete old files. if copy and delete: new_paths = [os.path.realpath(item.path) for item in items] for old_path in self.old_paths: # Only delete files that were actually copied. if old_path not in new_paths: util.remove(old_path, False) self.prune(old_path) # When moving, prune empty directories containing the original files. elif move: for old_path in self.old_paths: self.prune(old_path) def _emit_imported(self, lib: library.Library): plugins.send("album_imported", lib=lib, album=self.album) def handle_created(self, session: ImportSession): """Send the `import_task_created` event for this task. Return a list of tasks that should continue through the pipeline. By default, this is a list containing only the task itself, but plugins can replace the task with new ones. """ tasks = plugins.send("import_task_created", session=session, task=self) if not tasks: tasks = [self] else: # The plugins gave us a list of lists of tasks. Flatten it. tasks = [t for inner in tasks for t in inner] return tasks def lookup_candidates(self, search_ids: list[str]) -> None: """Retrieve and store candidates for this album. If User-specified ``search_ids`` list is not empty, the lookup is restricted to only those IDs. """ self.cur_artist, self.cur_album, (self.candidates, self.rec) = ( autotag.tag_album(self.items, search_ids=search_ids) ) def find_duplicates(self, lib: library.Library) -> list[library.Album]: """Return a list of albums from `lib` with the same artist and album name as the task. """ info = self.chosen_info() info["albumartist"] = info["artist"] if info["artist"] is None: # As-is import with no artist. Skip check. return [] # Construct a query to find duplicates with this metadata. We # use a temporary Album object to generate any computed fields. tmp_album = library.Album(lib, **info) keys: list[str] = config["import"]["duplicate_keys"][ "album" ].as_str_seq() dup_query = tmp_album.duplicates_query(keys) # Don't count albums with the same files as duplicates. task_paths = {i.path for i in self.items if i} duplicates = [] for album in lib.albums(dup_query): # Check whether the album paths are all present in the task # i.e. album is being completely re-imported by the task, # in which case it is not a duplicate (will be replaced). album_paths = {i.path for i in album.items()} if not (album_paths <= task_paths): duplicates.append(album) return duplicates def align_album_level_fields(self): """Make some album fields equal across `self.items`. For the RETAG action, we assume that the responsible for returning it (ie. a plugin) always ensures that the first item contains valid data on the relevant fields. """ changes = {} if self.choice_flag == Action.ASIS: # Taking metadata "as-is". Guess whether this album is VA. plur_albumartist, freq = util.plurality( [i.albumartist or i.artist for i in self.items] ) if freq == len(self.items) or ( freq > 1 and float(freq) / len(self.items) >= SINGLE_ARTIST_THRESH ): # Single-artist album. changes["albumartist"] = plur_albumartist changes["comp"] = False else: # VA. changes["albumartist"] = config["va_name"].as_str() changes["comp"] = True elif self.choice_flag in (Action.APPLY, Action.RETAG): # Applying autotagged metadata. Just get AA from the first # item. if not self.items[0].albumartist: changes["albumartist"] = self.items[0].artist if not self.items[0].albumartists: changes["albumartists"] = self.items[0].artists if not self.items[0].mb_albumartistid: changes["mb_albumartistid"] = self.items[0].mb_artistid if not self.items[0].mb_albumartistids: changes["mb_albumartistids"] = self.items[0].mb_artistids # Apply new metadata. for item in self.items: item.update(changes) def manipulate_files( self, session: ImportSession, operation: util.MoveOperation | None = None, write=False, ): """Copy, move, link, hardlink or reflink (depending on `operation`) the files as well as write metadata. `operation` should be an instance of `util.MoveOperation`. If `write` is `True` metadata is written to the files. # TODO: Introduce a MoveOperation.NONE or SKIP """ items = self.imported_items() # Save the original paths of all items for deletion and pruning # in the next step (finalization). self.old_paths: list[util.PathBytes] = [item.path for item in items] for item in items: if operation is not None: # In copy and link modes, treat re-imports specially: # move in-library files. (Out-of-library files are # copied/moved as usual). old_path = item.path if ( operation != util.MoveOperation.MOVE and self.replaced_items[item] and session.lib.directory in util.ancestry(old_path) ): item.move() # We moved the item, so remove the # now-nonexistent file from old_paths. self.old_paths.remove(old_path) else: # A normal import. Just copy files and keep track of # old paths. item.move(operation) if write and (self.apply or self.choice_flag == Action.RETAG): item.try_write() with session.lib.transaction(): for item in self.imported_items(): item.store() plugins.send("import_task_files", session=session, task=self) def add(self, lib: library.Library): """Add the items as an album to the library and remove replaced items.""" self.align_album_level_fields() with lib.transaction(): self.record_replaced(lib) self.remove_replaced(lib) self.album = lib.add_album(self.imported_items()) if self.choice_flag == Action.APPLY and isinstance( self.match, autotag.AlbumMatch ): # Copy album flexible fields to the DB # TODO: change the flow so we create the `Album` object earlier, # and we can move this into `self.apply_metadata`, just like # is done for tracks. autotag.apply_album_metadata(self.match.info, self.album) self.album.store() self.reimport_metadata(lib) def record_replaced(self, lib: library.Library): """Records the replaced items and albums in the `replaced_items` and `replaced_albums` dictionaries. """ self.replaced_items = defaultdict(list) self.replaced_albums: dict[util.PathBytes, library.Album] = ( defaultdict() ) replaced_album_ids = set() for item in self.imported_items(): dup_items = list(lib.items(query=PathQuery("path", item.path))) self.replaced_items[item] = dup_items for dup_item in dup_items: if ( not dup_item.album_id or dup_item.album_id in replaced_album_ids ): continue replaced_album = dup_item._cached_album if replaced_album: replaced_album_ids.add(dup_item.album_id) self.replaced_albums[replaced_album.path] = replaced_album def reimport_metadata(self, lib: library.Library): """For reimports, preserves metadata for reimported items and albums. """ def _reduce_and_log(new_obj, existing_fields, overwrite_keys): """Some flexible attributes should be overwritten (rather than preserved) on reimports; Copies existing_fields, logs and removes entries that should not be preserved and returns a dict containing those fields left to actually be preserved. """ noun = "album" if isinstance(new_obj, library.Album) else "item" existing_fields = dict(existing_fields) overwritten_fields = [ k for k in existing_fields if k in overwrite_keys and new_obj.get(k) and existing_fields.get(k) != new_obj.get(k) ] if overwritten_fields: log.debug( "Reimported {0} {1.id}. Not preserving flexible attributes {2}. " "Path: {1.filepath}", noun, new_obj, overwritten_fields, ) for key in overwritten_fields: del existing_fields[key] return existing_fields if self.is_album: replaced_album = self.replaced_albums.get(self.album.path) if replaced_album: album_fields = _reduce_and_log( self.album, replaced_album._values_flex, REIMPORT_FRESH_FIELDS_ALBUM, ) self.album.added = replaced_album.added self.album.update(album_fields) self.album.artpath = replaced_album.artpath self.album.store() log.debug( "Reimported album {0.album.id}. Preserving attribute ['added']. " "Path: {0.album.filepath}", self, ) log.debug( "Reimported album {0.album.id}. Preserving flexible" " attributes {1}. Path: {0.album.filepath}", self, list(album_fields.keys()), ) for item in self.imported_items(): dup_items = self.replaced_items[item] for dup_item in dup_items: if dup_item.added and dup_item.added != item.added: item.added = dup_item.added log.debug( "Reimported item {0.id}. Preserving attribute ['added']. " "Path: {0.filepath}", item, ) item_fields = _reduce_and_log( item, dup_item._values_flex, REIMPORT_FRESH_FIELDS_ITEM ) item.update(item_fields) log.debug( "Reimported item {0.id}. Preserving flexible attributes {1}. " "Path: {0.filepath}", item, list(item_fields.keys()), ) item.store() def remove_replaced(self, lib): """Removes all the items from the library that have the same path as an item from this task. """ for item in self.imported_items(): for dup_item in self.replaced_items[item]: log.debug("Replacing item {.id}: {.filepath}", dup_item, item) dup_item.remove() log.debug( "{} of {} items replaced", sum(bool(v) for v in self.replaced_items.values()), len(self.imported_items()), ) def choose_match(self, session): """Ask the session which match should apply and apply it.""" choice = session.choose_match(self) self.set_choice(choice) session.log_choice(self) def reload(self): """Reload albums and items from the database.""" for item in self.imported_items(): item.load() self.album.load() # Utilities. def prune(self, filename): """Prune any empty directories above the given file. If this task has no `toppath` or the file path provided is not within the `toppath`, then this function has no effect. Similarly, if the file still exists, no pruning is performed, so it's safe to call when the file in question may not have been removed. """ if self.toppath and not os.path.exists(util.syspath(filename)): util.prune_dirs( os.path.dirname(filename), self.toppath, clutter=config["clutter"].as_str_seq(), ) class SingletonImportTask(ImportTask): """ImportTask for a single track that is not associated to an album.""" def __init__(self, toppath: util.PathBytes | None, item: library.Item): super().__init__(toppath, [item.path], [item]) self.item = item self.is_album = False self.paths = [item.path] def chosen_info(self): """Return a dictionary of metadata about the current choice. May only be called when the choice flag is ASIS or RETAG (in which case the data comes from the files' current metadata) or APPLY (in which case the data comes from the choice). """ assert self.choice_flag in (Action.ASIS, Action.RETAG, Action.APPLY) if self.choice_flag in (Action.ASIS, Action.RETAG): return dict(self.item) elif self.choice_flag is Action.APPLY: return self.match.info.copy() def imported_items(self): return [self.item] def apply_metadata(self): if config["import"]["from_scratch"]: self.item.clear() autotag.apply_item_metadata(self.item, self.match.info) def _emit_imported(self, lib): for item in self.imported_items(): plugins.send("item_imported", lib=lib, item=item) def lookup_candidates(self, search_ids: list[str]) -> None: self.candidates, self.rec = autotag.tag_item( self.item, search_ids=search_ids ) def find_duplicates(self, lib: library.Library) -> list[library.Item]: # type: ignore[override] # Need splitting Singleton and Album tasks into separate classes """Return a list of items from `lib` that have the same artist and title as the task. """ info = self.chosen_info() # Query for existing items using the same metadata. We use a # temporary `Item` object to generate any computed fields. tmp_item = library.Item(lib, **info) keys: list[str] = config["import"]["duplicate_keys"][ "item" ].as_str_seq() dup_query = tmp_item.duplicates_query(keys) found_items = [] for other_item in lib.items(dup_query): # Existing items not considered duplicates. if other_item.path != self.item.path: found_items.append(other_item) return found_items duplicate_items = find_duplicates def add(self, lib): with lib.transaction(): self.record_replaced(lib) self.remove_replaced(lib) lib.add(self.item) self.reimport_metadata(lib) def infer_album_fields(self): raise NotImplementedError def choose_match(self, session: ImportSession): """Ask the session which match should apply and apply it.""" choice = session.choose_item(self) self.set_choice(choice) session.log_choice(self) def reload(self): self.item.load() def set_fields(self, lib): """Sets the fields given at CLI or configuration to the specified values, for the singleton item. """ for field, view in config["import"]["set_fields"].items(): value = str(view.get()) log.debug( "Set field {}={} for {}", field, value, util.displayable_path(self.paths), ) self.item.set_parse(field, format(self.item, value)) self.item.store() # FIXME The inheritance relationships are inverted. This is why there # are so many methods which pass. More responsibility should be delegated to # the BaseImportTask class. class SentinelImportTask(ImportTask): """A sentinel task marks the progress of an import and does not import any items itself. If only `toppath` is set the task indicates the end of a top-level directory import. If the `paths` argument is also given, the task indicates the progress in the `toppath` import. """ def __init__(self, toppath, paths): super().__init__(toppath, paths, ()) # TODO Remove the remaining attributes eventually self.should_remove_duplicates = False self.is_album = True self.choice_flag = None def save_history(self): pass def save_progress(self): if not self.paths: # "Done" sentinel. ImportState().progress_reset(self.toppath) elif self.toppath: # "Directory progress" sentinel for singletons super().save_progress() @property def skip(self) -> bool: return True def set_choice(self, choice): raise NotImplementedError def cleanup(self, copy=False, delete=False, move=False): pass def _emit_imported(self, lib): pass ArchiveHandler = tuple[ Callable[[util.StrPath], bool], Callable[[util.StrPath], Any] ] class ArchiveImportTask(SentinelImportTask): """An import task that represents the processing of an archive. `toppath` must be a `zip`, `tar`, or `rar` archive. Archive tasks serve two purposes: - First, it will unarchive the files to a temporary directory and return it. The client should read tasks from the resulting directory and send them through the pipeline. - Second, it will clean up the temporary directory when it proceeds through the pipeline. The client should send the archive task after sending the rest of the music tasks to make this work. """ def __init__(self, toppath): super().__init__(toppath, ()) self.extracted = False @classmethod def is_archive(cls, path): """Returns true if the given path points to an archive that can be handled. """ if not os.path.isfile(path): return False for path_test, _ in cls.handlers: if path_test(os.fsdecode(path)): return True return False @util.cached_classproperty def handlers(cls) -> list[ArchiveHandler]: """Returns a list of archive handlers. Each handler is a `(path_test, ArchiveClass)` tuple. `path_test` is a function that returns `True` if the given path can be handled by `ArchiveClass`. `ArchiveClass` is a class that implements the same interface as `tarfile.TarFile`. """ _handlers: list[ArchiveHandler] = [] from zipfile import ZipFile, is_zipfile _handlers.append((is_zipfile, ZipFile)) import tarfile _handlers.append((tarfile.is_tarfile, tarfile.open)) try: from rarfile import RarFile, is_rarfile except ImportError: pass else: _handlers.append((is_rarfile, RarFile)) try: from py7zr import SevenZipFile, is_7zfile except ImportError: pass else: _handlers.append((is_7zfile, SevenZipFile)) return _handlers def cleanup(self, copy=False, delete=False, move=False): """Removes the temporary directory the archive was extracted to.""" if self.extracted and self.toppath: log.debug( "Removing extracted directory: {}", util.displayable_path(self.toppath), ) shutil.rmtree(util.syspath(self.toppath)) def extract(self): """Extracts the archive to a temporary directory and sets `toppath` to that directory. """ assert self.toppath is not None, "toppath must be set" for path_test, handler_class in self.handlers: if path_test(os.fsdecode(self.toppath)): break else: raise ValueError(f"No handler found for archive: {self.toppath}") extract_to = mkdtemp() archive = handler_class(os.fsdecode(self.toppath), mode="r") try: archive.extractall(extract_to) # Adjust the files' mtimes to match the information from the # archive. Inspired by: https://stackoverflow.com/q/9813243 for f in archive.infolist(): # The date_time will need to adjusted otherwise # the item will have the current date_time of extraction. # The (0, 0, -1) is added to date_time because the # function time.mktime expects a 9-element tuple. # The -1 indicates that the DST flag is unknown. date_time = time.mktime((*f.date_time, 0, 0, -1)) fullpath = os.path.join(extract_to, f.filename) os.utime(fullpath, (date_time, date_time)) finally: archive.close() self.extracted = True self.toppath = extract_to class ImportTaskFactory: """Generate album and singleton import tasks for all media files indicated by a path. """ def __init__(self, toppath: util.PathBytes, session: ImportSession): """Create a new task factory. `toppath` is the user-specified path to search for music to import. `session` is the `ImportSession`, which controls how tasks are read from the directory. """ self.toppath = toppath self.session = session self.skipped = 0 # Skipped due to incremental/resume. self.imported = 0 # "Real" tasks created. self.is_archive = ArchiveImportTask.is_archive(util.syspath(toppath)) def tasks(self) -> Iterable[ImportTask]: """Yield all import tasks for music found in the user-specified path `self.toppath`. Any necessary sentinel tasks are also produced. During generation, update `self.skipped` and `self.imported` with the number of tasks that were not produced (due to incremental mode or resumed imports) and the number of concrete tasks actually produced, respectively. If `self.toppath` is an archive, it is adjusted to point to the extracted data. """ # Check whether this is an archive. archive_task: ArchiveImportTask | None = None if self.is_archive: archive_task = self.unarchive() if not archive_task: return # Search for music in the directory. for dirs, paths in self.paths(): if self.session.config["singletons"]: for path in paths: tasks = self._create(self.singleton(path)) yield from tasks yield self.sentinel(dirs) else: tasks = self._create(self.album(paths, dirs)) yield from tasks # Produce the final sentinel for this toppath to indicate that # it is finished. This is usually just a SentinelImportTask, but # for archive imports, send the archive task instead (to remove # the extracted directory). yield archive_task or self.sentinel() def _create(self, task: ImportTask | None): """Handle a new task to be emitted by the factory. Emit the `import_task_created` event and increment the `imported` count if the task is not skipped. Return the same task. If `task` is None, do nothing. """ if task: tasks = task.handle_created(self.session) self.imported += len(tasks) return tasks return [] def paths(self): """Walk `self.toppath` and yield `(dirs, files)` pairs where `files` are individual music files and `dirs` the set of containing directories where the music was found. This can either be a recursive search in the ordinary case, a single track when `toppath` is a file, a single directory in `flat` mode. """ if not os.path.isdir(util.syspath(self.toppath)): yield [self.toppath], [self.toppath] elif self.session.config["flat"]: paths = [] for dirs, paths_in_dir in albums_in_dir(self.toppath): paths += paths_in_dir yield [self.toppath], paths else: for dirs, paths in albums_in_dir(self.toppath): yield dirs, paths def singleton(self, path: util.PathBytes): """Return a `SingletonImportTask` for the music file.""" if self.session.already_imported(self.toppath, [path]): log.debug( "Skipping previously-imported path: {}", util.displayable_path(path), ) self.skipped += 1 return None item = self.read_item(path) if item: return SingletonImportTask(self.toppath, item) else: return None def album(self, paths: Iterable[util.PathBytes], dirs=None): """Return a `ImportTask` with all media files from paths. `dirs` is a list of parent directories used to record already imported albums. """ if dirs is None: dirs = list({os.path.dirname(p) for p in paths}) if self.session.already_imported(self.toppath, dirs): log.debug( "Skipping previously-imported path: {}", util.displayable_path(dirs), ) self.skipped += 1 return None items: list[library.Item] = [ item for item in map(self.read_item, paths) if item ] if len(items) > 0: return ImportTask(self.toppath, dirs, items) else: return None def sentinel(self, paths: Iterable[util.PathBytes] | None = None): """Return a `SentinelImportTask` indicating the end of a top-level directory import. """ return SentinelImportTask(self.toppath, paths) def unarchive(self): """Extract the archive for this `toppath`. Extract the archive to a new directory, adjust `toppath` to point to the extracted directory, and return an `ArchiveImportTask`. If extraction fails, return None. """ assert self.is_archive if not (self.session.config["move"] or self.session.config["copy"]): log.warning( "Archive importing requires either " "'copy' or 'move' to be enabled." ) return log.debug("Extracting archive: {}", util.displayable_path(self.toppath)) archive_task = ArchiveImportTask(self.toppath) try: archive_task.extract() except Exception as exc: log.error("extraction failed: {}", exc) return # Now read albums from the extracted directory. self.toppath = archive_task.toppath log.debug("Archive extracted to: {.toppath}", self) return archive_task def read_item(self, path: util.PathBytes): """Return an `Item` read from the path. If an item cannot be read, return `None` instead and log an error. """ try: return library.Item.from_path(path) except library.ReadError as exc: if isinstance(exc.reason, mediafile.FileTypeError): # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): log.warning("unreadable file: {}", util.displayable_path(path)) else: log.error( "error reading {}: {}", util.displayable_path(path), exc ) MULTIDISC_MARKERS = (rb"dis[ck]", rb"cd") MULTIDISC_PAT_FMT = rb"^(.*%s[\W_]*)\d" def is_subdir_of_any_in_list(path, dirs): """Returns True if path os a subdirectory of any directory in dirs (a list). In other case, returns False. """ ancestors = util.ancestry(path) return any(d in ancestors for d in dirs) def albums_in_dir(path: util.PathBytes): """Recursively searches the given directory and returns an iterable of (paths, items) where paths is a list of directories and items is a list of Items that is probably an album. Specifically, any folder containing any media files is an album. """ collapse_paths: list[util.PathBytes] = [] collapse_items: list[util.PathBytes] = [] collapse_pat = None ignore: list[str] = config["ignore"].as_str_seq() ignore_hidden: bool = config["ignore_hidden"].get(bool) for root, dirs, files in util.sorted_walk( path, ignore=ignore, ignore_hidden=ignore_hidden, logger=log ): items = [os.path.join(root, f) for f in files] # If we're currently collapsing the constituent directories in a # multi-disc album, check whether we should continue collapsing # and add the current directory. If so, just add the directory # and move on to the next directory. If not, stop collapsing. if collapse_paths: if (is_subdir_of_any_in_list(root, collapse_paths)) or ( collapse_pat and collapse_pat.match(os.path.basename(root)) ): # Still collapsing. collapse_paths.append(root) collapse_items += items continue else: # Collapse finished. Yield the collapsed directory and # proceed to process the current one. if collapse_items: yield collapse_paths, collapse_items collapse_pat, collapse_paths, collapse_items = None, [], [] # Check whether this directory looks like the *first* directory # in a multi-disc sequence. There are two indicators: the file # is named like part of a multi-disc sequence (e.g., "Title Disc # 1") or it contains no items but only directories that are # named in this way. start_collapsing = False for marker in MULTIDISC_MARKERS: # We're using replace on %s due to lack of .format() on bytestrings p = MULTIDISC_PAT_FMT.replace(b"%s", marker) marker_pat = re.compile(p, re.I) match = marker_pat.match(os.path.basename(root)) # Is this directory the root of a nested multi-disc album? if dirs and not items: # Check whether all subdirectories have the same prefix. start_collapsing = True subdir_pat = None for subdir in dirs: subdir = util.bytestring_path(subdir) # The first directory dictates the pattern for # the remaining directories. if not subdir_pat: match = marker_pat.match(subdir) if match: match_group = re.escape(match.group(1)) subdir_pat = re.compile( b"".join([b"^", match_group, rb"\d"]), re.I ) else: start_collapsing = False break # Subsequent directories must match the pattern. elif not subdir_pat.match(subdir): start_collapsing = False break # If all subdirectories match, don't check other # markers. if start_collapsing: break # Is this directory the first in a flattened multi-disc album? elif match: start_collapsing = True # Set the current pattern to match directories with the same # prefix as this one, followed by a digit. collapse_pat = re.compile( b"".join([b"^", re.escape(match.group(1)), rb"\d"]), re.I ) break # If either of the above heuristics indicated that this is the # beginning of a multi-disc album, initialize the collapsed # directory and item lists and check the next directory. if start_collapsing: # Start collapsing; continue to the next iteration. collapse_paths = [root] collapse_items = items continue # If it's nonempty, yield it. if items: yield [root], items # Clear out any unfinished collapse. if collapse_paths and collapse_items: yield collapse_paths, collapse_items ================================================ FILE: beets/library/__init__.py ================================================ from beets.util.deprecation import deprecate_imports from .exceptions import FileOperationError, ReadError, WriteError from .library import Library from .models import Album, Item, LibModel from .queries import parse_query_parts, parse_query_string NEW_MODULE_BY_NAME = dict.fromkeys( ("DateType", "DurationType", "MusicalKey", "PathType"), "beets.dbcore.types" ) | dict.fromkeys( ("BLOB_TYPE", "SingletonQuery", "PathQuery"), "beets.dbcore.query" ) def __getattr__(name: str): return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name) __all__ = [ "Album", "FileOperationError", "Item", "LibModel", "Library", "ReadError", "WriteError", "parse_query_parts", "parse_query_string", ] ================================================ FILE: beets/library/exceptions.py ================================================ from beets import util class FileOperationError(Exception): """Indicate an error when interacting with a file on disk. Possibilities include an unsupported media type, a permissions error, and an unhandled Mutagen exception. """ def __init__(self, path, reason): """Create an exception describing an operation on the file at `path` with the underlying (chained) exception `reason`. """ super().__init__(path, reason) self.path = path self.reason = reason def __str__(self): """Get a string representing the error. Describe both the underlying reason and the file path in question. """ return f"{util.displayable_path(self.path)}: {self.reason}" class ReadError(FileOperationError): """An error while reading a file (i.e. in `Item.read`).""" def __str__(self): return f"error reading {super()}" class WriteError(FileOperationError): """An error while writing a file (i.e. in `Item.write`).""" def __str__(self): return f"error writing {super()}" ================================================ FILE: beets/library/library.py ================================================ from __future__ import annotations from typing import TYPE_CHECKING import platformdirs import beets from beets import dbcore from beets.util import normpath from . import migrations from .models import Album, Item from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string if TYPE_CHECKING: from beets.dbcore import Results class Library(dbcore.Database): """A database of music containing songs and albums.""" _models = (Item, Album) _migrations = ( (migrations.MultiGenreFieldMigration, (Item, Album)), (migrations.LyricsMetadataInFlexFieldsMigration, (Item,)), ) def __init__( self, path="library.blb", directory: str | None = None, path_formats=((PF_KEY_DEFAULT, "$artist/$album/$track $title"),), replacements=None, ): timeout = beets.config["timeout"].as_number() super().__init__(path, timeout=timeout) self.directory = normpath(directory or platformdirs.user_music_path()) self.path_formats = path_formats self.replacements = replacements # Used for template substitution performance. self._memotable: dict[tuple[str, ...], str] = {} # Adding objects to the database. def add(self, obj): """Add the :class:`Item` or :class:`Album` object to the library database. Return the object's new id. """ obj.add(self) self._memotable = {} return obj.id def add_album(self, items): """Create a new album consisting of a list of items. The items are added to the database if they don't yet have an ID. Return a new :class:`Album` object. The list items must not be empty. """ if not items: raise ValueError("need at least one item") # Create the album structure using metadata from the first item. values = {key: items[0][key] for key in Album.item_keys} album = Album(self, **values) # Add the album structure and set the items' album_id fields. # Store or add the items. with self.transaction(): album.add(self) for item in items: item.album_id = album.id if item.id is None: item.add(self) else: item.store() return album # Querying. def _fetch(self, model_cls, query, sort=None): """Parse a query and fetch. If an order specification is present in the query string the `sort` argument is ignored. """ # Parse the query, if necessary. try: parsed_sort = None if isinstance(query, str): query, parsed_sort = parse_query_string(query, model_cls) elif isinstance(query, (list, tuple)): query, parsed_sort = parse_query_parts(query, model_cls) except dbcore.query.InvalidQueryArgumentValueError as exc: raise dbcore.InvalidQueryError(query, exc) # Any non-null sort specified by the parsed query overrides the # provided sort. if parsed_sort and not isinstance(parsed_sort, dbcore.query.NullSort): sort = parsed_sort return super()._fetch(model_cls, query, sort) @staticmethod def get_default_album_sort(): """Get a :class:`Sort` object for albums from the config option.""" return dbcore.sort_from_strings( Album, beets.config["sort_album"].as_str_seq() ) @staticmethod def get_default_item_sort(): """Get a :class:`Sort` object for items from the config option.""" return dbcore.sort_from_strings( Item, beets.config["sort_item"].as_str_seq() ) def albums(self, query=None, sort=None) -> Results[Album]: """Get :class:`Album` objects matching the query.""" return self._fetch(Album, query, sort or self.get_default_album_sort()) def items(self, query=None, sort=None) -> Results[Item]: """Get :class:`Item` objects matching the query.""" return self._fetch(Item, query, sort or self.get_default_item_sort()) # Convenience accessors. def get_item(self, id_: int) -> Item | None: """Fetch a :class:`Item` by its ID. Return `None` if no match is found. """ return self._get(Item, id_) def get_album(self, item_or_id: Item | int) -> Album | None: """Given an album ID or an item associated with an album, return a :class:`Album` object for the album. If no such album exists, return `None`. """ album_id = ( item_or_id if isinstance(item_or_id, int) else item_or_id.album_id ) return self._get(Album, album_id) if album_id else None ================================================ FILE: beets/library/migrations.py ================================================ from __future__ import annotations from contextlib import suppress from functools import cached_property from typing import TYPE_CHECKING, NamedTuple, TypeVar from confuse.exceptions import ConfigError import beets from beets import ui from beets.dbcore.db import Migration from beets.dbcore.types import MULTI_VALUE_DELIMITER from beets.util import unique_list from beets.util.lyrics import Lyrics if TYPE_CHECKING: from collections.abc import Iterator from beets.dbcore.db import Model T = TypeVar("T") class GenreRow(NamedTuple): id: int genre: str genres: str | None def chunks(lst: list[T], n: int) -> Iterator[list[T]]: """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i : i + n] class MultiGenreFieldMigration(Migration): """Backfill multi-value genres from legacy single-string genre data.""" @cached_property def separators(self) -> list[str]: """Return known separators that indicate multiple legacy genres.""" separators = [] with suppress(ConfigError): separators.append(beets.config["lastgenre"]["separator"].as_str()) separators.extend(["; ", ", ", " / "]) return unique_list(filter(None, separators)) def get_genres(self, genre: str) -> str: """Normalize legacy genre separators to the canonical delimiter.""" for separator in self.separators: if separator in genre: return genre.replace(separator, MULTI_VALUE_DELIMITER) return genre def _migrate_data( self, model_cls: type[Model], current_fields: set[str] ) -> None: """Migrate legacy genre values to the multi-value genres field.""" if "genre" not in current_fields: # No legacy genre field, so nothing to migrate. return table = model_cls._table with self.db.transaction() as tx, self.with_row_factory(GenreRow): rows: list[GenreRow] = tx.query( # type: ignore[assignment] f""" SELECT id, genre, genres FROM {table} WHERE genre IS NOT NULL AND genre != '' """ ) total = len(rows) to_migrate = [e for e in rows if not e.genres] if not to_migrate: return migrated = total - len(to_migrate) ui.print_(f"Migrating genres for {total} {table}...") for batch in chunks(to_migrate, 1000): with self.db.transaction() as tx: tx.mutate_many( f"UPDATE {table} SET genres = ? WHERE id = ?", [(self.get_genres(e.genre), e.id) for e in batch], ) migrated += len(batch) ui.print_( f" Migrated {migrated} {table} " f"({migrated}/{total} processed)..." ) ui.print_(f"Migration complete: {migrated} of {total} {table} updated") class LyricsRow(NamedTuple): id: int lyrics: str class LyricsMetadataInFlexFieldsMigration(Migration): """Move legacy inline lyrics metadata into dedicated flexible fields.""" def _migrate_data(self, model_cls: type[Model], _: set[str]) -> None: """Migrate legacy lyrics to move metadata to flex attributes.""" table = model_cls._table flex_table = model_cls._flex_table with self.db.transaction() as tx: migrated_ids = { r[0] for r in tx.query( f""" SELECT entity_id FROM {flex_table} WHERE key == 'lyrics_backend' """ ) } with self.db.transaction() as tx, self.with_row_factory(LyricsRow): rows: list[LyricsRow] = tx.query( # type: ignore[assignment] f""" SELECT id, lyrics FROM {table} WHERE lyrics IS NOT NULL AND lyrics != '' """ ) total = len(rows) to_migrate = [r for r in rows if r.id not in migrated_ids] if not to_migrate: return migrated = total - len(to_migrate) ui.print_(f"Migrating lyrics for {total} {table}...") lyr_fields = ["backend", "url", "language", "translation_language"] for batch in chunks(to_migrate, 100): lyrics_batch = [Lyrics.from_legacy_text(r.lyrics) for r in batch] ids_with_lyrics = [ (lyr, r.id) for lyr, r in zip(lyrics_batch, batch) ] with self.db.transaction() as tx: update_rows = [ (lyr.full_text, r.id) for lyr, r in zip(lyrics_batch, batch) if lyr.full_text != r.lyrics ] if update_rows: tx.mutate_many( f"UPDATE {table} SET lyrics = ? WHERE id = ?", update_rows, ) # Only insert flex rows for non-null metadata values flex_rows = [ (_id, f"lyrics_{field}", val) for lyr, _id in ids_with_lyrics for field in lyr_fields if (val := getattr(lyr, field)) is not None ] if flex_rows: tx.mutate_many( f""" INSERT INTO {flex_table} (entity_id, key, value) VALUES (?, ?, ?) """, flex_rows, ) migrated += len(batch) ui.print_( f" Migrated {migrated} {table} " f"({migrated}/{total} processed)..." ) ui.print_(f"Migration complete: {migrated} of {total} {table} updated") ================================================ FILE: beets/library/models.py ================================================ from __future__ import annotations import os import string import sys import time import unicodedata from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, ClassVar from mediafile import MediaFile, UnreadableFileError import beets from beets import dbcore, logging, plugins, util from beets.dbcore import types from beets.util import ( MoveOperation, bytestring_path, cached_classproperty, normpath, samefile, syspath, ) from beets.util.functemplate import Template, template from .exceptions import FileOperationError, ReadError, WriteError from .queries import PF_KEY_DEFAULT, parse_query_string if TYPE_CHECKING: from ..dbcore.query import FieldQuery, FieldQueryType from .library import Library # noqa: F401 log = logging.getLogger("beets") class LibModel(dbcore.Model["Library"]): """Shared concrete functionality for Items and Albums.""" # Config key that specifies how an instance should be formatted. _format_config_key: str path: bytes length: float @cached_classproperty def _types(cls) -> dict[str, types.Type]: """Return the types of the fields in this model.""" return { **plugins.types(cls), # type: ignore[arg-type] "data_source": types.STRING, } @cached_classproperty def _queries(cls) -> dict[str, FieldQueryType]: return plugins.named_queries(cls) # type: ignore[arg-type] @cached_classproperty def writable_media_fields(cls) -> set[str]: return set(MediaFile.fields()) & cls._fields.keys() @property def filepath(self) -> Path: """The path to the entity as pathlib.Path.""" return Path(os.fsdecode(self.path)) def _template_funcs(self): funcs = DefaultTemplateFunctions(self, self._db).functions() funcs.update(plugins.template_funcs()) return funcs def store(self, fields=None): super().store(fields) plugins.send("database_change", lib=self._db, model=self) def remove(self): super().remove() plugins.send("database_change", lib=self._db, model=self) def add(self, lib=None): # super().add() calls self.store(), which sends `database_change`, # so don't do it here super().add(lib) def __format__(self, spec): if not spec: spec = beets.config[self._format_config_key].as_str() assert isinstance(spec, str) return self.evaluate_template(spec) def __str__(self): return format(self) def __bytes__(self): return self.__str__().encode("utf-8") # Convenient queries. @classmethod def field_query( cls, field: str, pattern: str, query_cls: FieldQueryType ) -> FieldQuery: """Get a `FieldQuery` for the given field on this model.""" fast = field in cls.all_db_fields if field in cls.shared_db_fields: # This field exists in both tables, so SQLite will encounter # an OperationalError if we try to use it in a query. # Using an explicit table name resolves this. field = f"{cls._table}.{field}" return query_cls(field, pattern, fast) @classmethod def any_field_query(cls, *args, **kwargs) -> dbcore.OrQuery: return dbcore.OrQuery( [cls.field_query(f, *args, **kwargs) for f in cls._search_fields] ) @classmethod def any_writable_media_field_query(cls, *args, **kwargs) -> dbcore.OrQuery: fields = cls.writable_media_fields return dbcore.OrQuery( [cls.field_query(f, *args, **kwargs) for f in fields] ) def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery: """Return a query for entities with same values in the given fields.""" return dbcore.AndQuery( [ self.field_query(f, self.get(f), dbcore.MatchQuery) for f in fields ] ) class FormattedItemMapping(dbcore.db.FormattedMapping): """Add lookup for album-level fields. Album-level fields take precedence if `for_path` is true. """ ALL_KEYS = "*" def __init__(self, item, included_keys=ALL_KEYS, for_path=False): # We treat album and item keys specially here, # so exclude transitive album keys from the model's keys. super().__init__(item, included_keys=[], for_path=for_path) self.included_keys = included_keys if included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. self.model_keys = item.keys(computed=True, with_album=False) else: self.model_keys = included_keys self.item = item @cached_property def all_keys(self): return set(self.model_keys).union(self.album_keys) @cached_property def album_keys(self): album_keys = [] if self.album: if self.included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. for key in self.album.keys(computed=True): if ( key in Album.item_keys or key not in self.item._fields.keys() ): album_keys.append(key) else: album_keys = self.included_keys return album_keys @property def album(self): return self.item._cached_album def _get(self, key): """Get the value for a key, either from the album or the item. Raise a KeyError for invalid keys. """ if self.for_path and key in self.album_keys: return self._get_formatted(self.album, key) elif key in self.model_keys: return self._get_formatted(self.model, key) elif key in self.album_keys: return self._get_formatted(self.album, key) else: raise KeyError(key) def __getitem__(self, key): """Get the value for a key. `artist` and `albumartist` are fallback values for each other when not set. """ value = self._get(key) # `artist` and `albumartist` fields fall back to one another. # This is helpful in path formats when the album artist is unset # on as-is imports. try: if key == "artist" and not value: return self._get("albumartist") elif key == "albumartist" and not value: return self._get("artist") except KeyError: pass return value def __iter__(self): return iter(self.all_keys) def __len__(self): return len(self.all_keys) class Album(LibModel): """Provide access to information about albums stored in a library. Reflects the library's "albums" table, including album art. """ artpath: bytes _table = "albums" _flex_table = "album_attributes" _always_dirty = True _fields: ClassVar[dict[str, types.Type]] = { "id": types.PRIMARY_ID, "artpath": types.NullPathType(), "added": types.DATE, "albumartist": types.STRING, "albumartist_sort": types.STRING, "albumartist_credit": types.STRING, "albumartists": types.MULTI_VALUE_DSV, "albumartists_sort": types.MULTI_VALUE_DSV, "albumartists_credit": types.MULTI_VALUE_DSV, "album": types.STRING, "genres": types.MULTI_VALUE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, "discogs_labelid": types.INTEGER, "year": types.PaddedInt(4), "month": types.PaddedInt(2), "day": types.PaddedInt(2), "disctotal": types.PaddedInt(2), "comp": types.BOOLEAN, "mb_albumid": types.STRING, "mb_albumartistid": types.STRING, "mb_albumartistids": types.MULTI_VALUE_DSV, "albumtype": types.STRING, "albumtypes": types.SEMICOLON_SPACE_DSV, "label": types.STRING, "barcode": types.STRING, "mb_releasegroupid": types.STRING, "release_group_title": types.STRING, "asin": types.STRING, "catalognum": types.STRING, "script": types.STRING, "language": types.STRING, "country": types.STRING, "albumstatus": types.STRING, "albumdisambig": types.STRING, "releasegroupdisambig": types.STRING, "rg_album_gain": types.NULL_FLOAT, "rg_album_peak": types.NULL_FLOAT, "r128_album_gain": types.NULL_FLOAT, "original_year": types.PaddedInt(4), "original_month": types.PaddedInt(2), "original_day": types.PaddedInt(2), } _search_fields = ("album", "albumartist", "genres") @cached_classproperty def _types(cls) -> dict[str, types.Type]: return {**super()._types, "path": types.PathType()} _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { "albumartist": dbcore.query.SmartArtistSort, "artist": dbcore.query.SmartArtistSort, } # List of keys that are set on an album's items. item_keys: ClassVar[list[str]] = [ "added", "albumartist", "albumartists", "albumartist_sort", "albumartists_sort", "albumartist_credit", "albumartists_credit", "album", "genres", "style", "discogs_albumid", "discogs_artistid", "discogs_labelid", "year", "month", "day", "disctotal", "comp", "mb_albumid", "mb_albumartistid", "mb_albumartistids", "albumtype", "albumtypes", "label", "barcode", "mb_releasegroupid", "asin", "catalognum", "script", "language", "country", "albumstatus", "albumdisambig", "releasegroupdisambig", "release_group_title", "rg_album_gain", "rg_album_peak", "r128_album_gain", "original_year", "original_month", "original_day", ] _format_config_key = "format_album" @cached_classproperty def _relation(cls) -> type[Item]: return Item @cached_classproperty def relation_join(cls) -> str: """Return FROM clause which joins on related album items. Use LEFT join to select all albums, including those that do not have any items. """ return ( f"LEFT JOIN {cls._relation._table} " f"ON {cls._table}.id = {cls._relation._table}.album_id" ) @property def art_filepath(self) -> Path | None: """The path to album's cover picture as pathlib.Path.""" return Path(os.fsdecode(self.artpath)) if self.artpath else None @classmethod def _getters(cls): # In addition to plugin-provided computed fields, also expose # the album's directory as `path`. getters = plugins.album_field_getters() getters["path"] = Album.item_dir getters["albumtotal"] = Album._albumtotal return getters def items(self): """Return an iterable over the items associated with this album. This method conflicts with :meth:`LibModel.items`, which is inherited from :meth:`beets.dbcore.Model.items`. Since :meth:`Album.items` predates these methods, and is likely to be used by plugins, we keep this interface as-is. """ return self._db.items(dbcore.MatchQuery("album_id", self.id)) def remove(self, delete=False, with_items=True): """Remove this album and all its associated items from the library. If delete, then the items' files are also deleted from disk, along with any album art. The directories containing the album are also removed (recursively) if empty. Set with_items to False to avoid removing the album's items. """ super().remove() # Send a 'album_removed' signal to plugins plugins.send("album_removed", album=self) # Delete art file. if delete: artpath = self.artpath if artpath: util.remove(artpath) # Remove (and possibly delete) the constituent items. if with_items: for item in self.items(): item.remove(delete, False) def move_art(self, operation=MoveOperation.MOVE): """Move, copy, link or hardlink (depending on `operation`) any existing album art so that it remains in the same directory as the items. `operation` should be an instance of `util.MoveOperation`. """ old_art = self.artpath if not old_art: return if not os.path.exists(syspath(old_art)): log.error( "removing reference to missing album art file {}", util.displayable_path(old_art), ) self.artpath = None return new_art = self.art_destination(old_art) if new_art == old_art: return new_art = util.unique_path(new_art) log.debug( "moving album art {} to {}", util.displayable_path(old_art), util.displayable_path(new_art), ) if operation == MoveOperation.MOVE: util.move(old_art, new_art) util.prune_dirs(os.path.dirname(old_art), self._db.directory) elif operation == MoveOperation.COPY: util.copy(old_art, new_art) elif operation == MoveOperation.LINK: util.link(old_art, new_art) elif operation == MoveOperation.HARDLINK: util.hardlink(old_art, new_art) elif operation == MoveOperation.REFLINK: util.reflink(old_art, new_art, fallback=False) elif operation == MoveOperation.REFLINK_AUTO: util.reflink(old_art, new_art, fallback=True) else: assert False, "unknown MoveOperation" self.artpath = new_art def move(self, operation=MoveOperation.MOVE, basedir=None, store=True): """Move, copy, link or hardlink (depending on `operation`) all items to their destination. Any album art moves along with them. `basedir` overrides the library base directory for the destination. `operation` should be an instance of `util.MoveOperation`. By default, the album is stored to the database, persisting any modifications to its metadata. If `store` is `False` however, the album is not stored automatically, and it will have to be manually stored after invoking this method. """ basedir = basedir or self._db.directory # Ensure new metadata is available to items for destination # computation. if store: self.store() # Move items. items = list(self.items()) for item in items: item.move(operation, basedir=basedir, with_album=False, store=store) # Move art. self.move_art(operation) if store: self.store() def item_dir(self): """Return the directory containing the album's first item, provided that such an item exists. """ item = self.items().get() if not item: raise ValueError(f"empty album for album id {self.id}") return os.path.dirname(item.path) def _albumtotal(self): """Return the total number of tracks on all discs on the album.""" if self.disctotal == 1 or not beets.config["per_disc_numbering"]: return self.items()[0].tracktotal counted = [] total = 0 for item in self.items(): if item.disc in counted: continue total += item.tracktotal counted.append(item.disc) if len(counted) == self.disctotal: break return total def art_destination(self, image, item_dir=None): """Return a path to the destination for the album art image for the album. `image` is the path of the image that will be moved there (used for its extension). The path construction uses the existing path of the album's items, so the album must contain at least one item or item_dir must be provided. """ image = bytestring_path(image) item_dir = item_dir or self.item_dir() filename_tmpl = template(beets.config["art_filename"].as_str()) subpath = self.evaluate_template(filename_tmpl, True) if beets.config["asciify_paths"]: subpath = util.asciify_path( subpath, beets.config["path_sep_replace"].as_str() ) subpath = util.sanitize_path( subpath, replacements=self._db.replacements ) subpath = bytestring_path(subpath) _, ext = os.path.splitext(image) dest = os.path.join(item_dir, subpath + ext) return bytestring_path(dest) def set_art(self, path, copy=True): """Set the album's cover art to the image at the given path. The image is copied (or moved) into place, replacing any existing art. Send an 'art_set' event with `self` as the sole argument. """ path = bytestring_path(path) oldart = self.artpath artdest = self.art_destination(path) if oldart and samefile(path, oldart): # Art already set. return elif samefile(path, artdest): # Art already in place. self.artpath = path return # Normal operation. if oldart == artdest: util.remove(oldart) artdest = util.unique_path(artdest) if copy: util.copy(path, artdest) else: util.move(path, artdest) self.artpath = artdest plugins.send("art_set", album=self) def store(self, fields=None, inherit=True): """Update the database with the album information. `fields` represents the fields to be stored. If not specified, all fields will be. The album's tracks are also updated when the `inherit` flag is enabled. This applies to fixed attributes as well as flexible ones. The `id` attribute of the album will never be inherited. """ # Get modified track fields. track_updates = {} track_deletes = set() for key in self._dirty: if inherit: if key in self.item_keys: # is a fixed attribute track_updates[key] = self[key] elif key not in self: # is a fixed or a flexible attribute track_deletes.add(key) elif key != "id": # is a flexible attribute track_updates[key] = self[key] with self._db.transaction(): super().store(fields) if track_updates: for item in self.items(): for key, value in track_updates.items(): item[key] = value item.store() if track_deletes: for item in self.items(): for key in track_deletes: if key in item: del item[key] item.store() def try_sync(self, write, move, inherit=True): """Synchronize the album and its items with the database. Optionally, also write any new tags into the files and update their paths. `write` indicates whether to write tags to the item files, and `move` controls whether files (both audio and album art) are moved. """ self.store(inherit=inherit) for item in self.items(): item.try_sync(write, move) @cached_property def length(self) -> float: # type: ignore[override] # still writable since we override __setattr__ """Return the total length of all items in this album in seconds.""" return sum(item.length for item in self.items()) class Item(LibModel): """Represent a song or track.""" album_id: int | None _table = "items" _flex_table = "item_attributes" _fields: ClassVar[dict[str, types.Type]] = { "id": types.PRIMARY_ID, "path": types.PathType(), "album_id": types.FOREIGN_ID, "title": types.STRING, "artist": types.STRING, "artists": types.MULTI_VALUE_DSV, "artists_ids": types.MULTI_VALUE_DSV, "artist_sort": types.STRING, "artists_sort": types.MULTI_VALUE_DSV, "artist_credit": types.STRING, "artists_credit": types.MULTI_VALUE_DSV, "remixer": types.STRING, "album": types.STRING, "albumartist": types.STRING, "albumartists": types.MULTI_VALUE_DSV, "albumartist_sort": types.STRING, "albumartists_sort": types.MULTI_VALUE_DSV, "albumartist_credit": types.STRING, "albumartists_credit": types.MULTI_VALUE_DSV, "genres": types.MULTI_VALUE_DSV, "style": types.STRING, "discogs_albumid": types.INTEGER, "discogs_artistid": types.INTEGER, "discogs_labelid": types.INTEGER, "lyricist": types.STRING, "composer": types.STRING, "composer_sort": types.STRING, "work": types.STRING, "mb_workid": types.STRING, "work_disambig": types.STRING, "arranger": types.STRING, "grouping": types.STRING, "year": types.PaddedInt(4), "month": types.PaddedInt(2), "day": types.PaddedInt(2), "track": types.PaddedInt(2), "tracktotal": types.PaddedInt(2), "disc": types.PaddedInt(2), "disctotal": types.PaddedInt(2), "lyrics": types.STRING, "comments": types.STRING, "bpm": types.INTEGER, "comp": types.BOOLEAN, "mb_trackid": types.STRING, "mb_albumid": types.STRING, "mb_artistid": types.STRING, "mb_artistids": types.MULTI_VALUE_DSV, "mb_albumartistid": types.STRING, "mb_albumartistids": types.MULTI_VALUE_DSV, "mb_releasetrackid": types.STRING, "trackdisambig": types.STRING, "albumtype": types.STRING, "albumtypes": types.SEMICOLON_SPACE_DSV, "label": types.STRING, "barcode": types.STRING, "acoustid_fingerprint": types.STRING, "acoustid_id": types.STRING, "mb_releasegroupid": types.STRING, "release_group_title": types.STRING, "asin": types.STRING, "isrc": types.STRING, "catalognum": types.STRING, "script": types.STRING, "language": types.STRING, "country": types.STRING, "albumstatus": types.STRING, "media": types.STRING, "albumdisambig": types.STRING, "releasegroupdisambig": types.STRING, "disctitle": types.STRING, "encoder": types.STRING, "rg_track_gain": types.NULL_FLOAT, "rg_track_peak": types.NULL_FLOAT, "rg_album_gain": types.NULL_FLOAT, "rg_album_peak": types.NULL_FLOAT, "r128_track_gain": types.NULL_FLOAT, "r128_album_gain": types.NULL_FLOAT, "original_year": types.PaddedInt(4), "original_month": types.PaddedInt(2), "original_day": types.PaddedInt(2), "initial_key": types.MusicalKey(), "length": types.DurationType(), "bitrate": types.ScaledInt(1000, "kbps"), "bitrate_mode": types.STRING, "encoder_info": types.STRING, "encoder_settings": types.STRING, "format": types.STRING, "samplerate": types.ScaledInt(1000, "kHz"), "bitdepth": types.INTEGER, "channels": types.INTEGER, "mtime": types.DATE, "added": types.DATE, } _indices = (dbcore.Index("idx_item_album_id", ("album_id",)),) _search_fields = ( "artist", "title", "comments", "album", "albumartist", "genres", ) # Set of item fields that are backed by `MediaFile` fields. # Any kind of field (fixed, flexible, and computed) may be a media # field. Only these fields are read from disk in `read` and written in # `write`. _media_fields = set(MediaFile.readable_fields()).intersection( _fields.keys() ) # Set of item fields that are backed by *writable* `MediaFile` tag # fields. # This excludes fields that represent audio data, such as `bitrate` or # `length`. _media_tag_fields = set(MediaFile.fields()).intersection(_fields.keys()) _formatter = FormattedItemMapping _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { "artist": dbcore.query.SmartArtistSort } @cached_classproperty def _queries(cls) -> dict[str, FieldQueryType]: return {**super()._queries, "singleton": dbcore.query.SingletonQuery} _format_config_key = "format_item" # Cached album object. Read-only. __album: Album | None = None @cached_classproperty def _relation(cls) -> type[Album]: return Album @cached_classproperty def relation_join(cls) -> str: """Return the FROM clause which includes related albums. We need to use a LEFT JOIN here, otherwise items that are not part of an album (e.g. singletons) would be left out. """ return ( f"LEFT JOIN {cls._relation._table} " f"ON {cls._table}.album_id = {cls._relation._table}.id" ) @property def _cached_album(self): """The Album object that this item belongs to, if any, or None if the item is a singleton or is not associated with a library. The instance is cached and refreshed on access. DO NOT MODIFY! If you want a copy to modify, use :meth:`get_album`. """ if not self.__album and self._db: self.__album = self._db.get_album(self) elif self.__album: self.__album.load() return self.__album @_cached_album.setter def _cached_album(self, album): self.__album = album @classmethod def _getters(cls): getters = plugins.item_field_getters() getters["singleton"] = lambda i: i.album_id is None getters["filesize"] = Item.try_filesize # In bytes. return getters def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery: """Return a query for entities with same values in the given fields.""" return super().duplicates_query(fields) & dbcore.query.NoneQuery( "album_id" ) @classmethod def from_path(cls, path): """Create a new item from the media file at the specified path.""" # Initiate with values that aren't read from files. i = cls(album_id=None) i.read(path) i.mtime = i.current_mtime() # Initial mtime. return i def __setitem__(self, key, value): """Set the item's value for a standard field or a flexattr.""" # Encode unicode paths and read buffers. if key == "path": if isinstance(value, str): value = bytestring_path(value) elif isinstance(value, types.BLOB_TYPE): value = bytes(value) elif key == "album_id": self._cached_album = None changed = super()._setitem(key, value) if changed and key in MediaFile.fields(): self.mtime = 0 # Reset mtime on dirty. def __getitem__(self, key): """Get the value for a field, falling back to the album if necessary. Raise a KeyError if the field is not available. """ try: return super().__getitem__(key) except KeyError: if self._cached_album: return self._cached_album[key] raise def __repr__(self): # This must not use `with_album=True`, because that might access # the database. When debugging, that is not guaranteed to succeed, and # can even deadlock due to the database lock. return ( f"{type(self).__name__}" f"({', '.join(f'{k}={self[k]!r}' for k in self.keys(with_album=False))})" ) def keys(self, computed=False, with_album=True): """Get a list of available field names. `with_album` controls whether the album's fields are included. """ keys = super().keys(computed=computed) if with_album and self._cached_album: keys = set(keys) keys.update(self._cached_album.keys(computed=computed)) keys = list(keys) return keys def get(self, key, default=None, with_album=True): """Get the value for a given key or `default` if it does not exist. Set `with_album` to false to skip album fallback. """ try: return self._get(key, default, raise_=with_album) except KeyError: if self._cached_album: return self._cached_album.get(key, default) return default def update(self, values): """Set all key/value pairs in the mapping. If mtime is specified, it is not reset (as it might otherwise be). """ super().update(values) if self.mtime == 0 and "mtime" in values: self.mtime = values["mtime"] def clear(self): """Set all key/value pairs to None.""" for key in self._media_tag_fields: setattr(self, key, None) def get_album(self): """Get the Album object that this item belongs to, if any, or None if the item is a singleton or is not associated with a library. """ if not self._db: return None return self._db.get_album(self) # Interaction with file metadata. def read(self, read_path=None): """Read the metadata from the associated file. If `read_path` is specified, read metadata from that file instead. Update all the properties in `_media_fields` from the media file. Raise a `ReadError` if the file could not be read. """ if read_path is None: read_path = self.path else: read_path = normpath(read_path) try: mediafile = MediaFile(syspath(read_path)) except UnreadableFileError as exc: raise ReadError(read_path, exc) for key in self._media_fields: value = getattr(mediafile, key) if isinstance(value, int): if value.bit_length() > 63: value = 0 self[key] = value # Database's mtime should now reflect the on-disk value. if read_path == self.path: self.mtime = self.current_mtime() self.path = read_path def write(self, path=None, tags=None, id3v23=None): """Write the item's metadata to a media file. All fields in `_media_fields` are written to disk according to the values on this object. `path` is the path of the mediafile to write the data to. It defaults to the item's path. `tags` is a dictionary of additional metadata the should be written to the file. (These tags need not be in `_media_fields`.) `id3v23` will override the global `id3v23` config option if it is set to something other than `None`. Can raise either a `ReadError` or a `WriteError`. """ if path is None: path = self.path else: path = normpath(path) if id3v23 is None: id3v23 = beets.config["id3v23"].get(bool) # Get the data to write to the file. item_tags = dict(self) item_tags = { k: v for k, v in item_tags.items() if k in self._media_fields } # Only write media fields. if tags is not None: item_tags.update(tags) plugins.send("write", item=self, path=path, tags=item_tags) # Open the file. try: mediafile = MediaFile(syspath(path), id3v23=id3v23) except UnreadableFileError as exc: raise ReadError(path, exc) # Write the tags to the file. mediafile.update(item_tags) try: mediafile.save() except UnreadableFileError as exc: raise WriteError(self.path, exc) # The file has a new mtime. if path == self.path: self.mtime = self.current_mtime() plugins.send("after_write", item=self, path=path) def try_write(self, *args, **kwargs): """Call `write()` but catch and log `FileOperationError` exceptions. Return `False` an exception was caught and `True` otherwise. """ try: self.write(*args, **kwargs) return True except FileOperationError as exc: log.error("{}", exc) return False def try_sync(self, write, move, with_album=True): """Synchronize the item with the database and, possibly, update its tags on disk and its path (by moving the file). `write` indicates whether to write new tags into the file. Similarly, `move` controls whether the path should be updated. In the latter case, files are *only* moved when they are inside their library's directory (if any). Similar to calling :meth:`write`, :meth:`move`, and :meth:`store` (conditionally). """ if write: self.try_write() if move: # Check whether this file is inside the library directory. if self._db and self._db.directory in util.ancestry(self.path): log.debug("moving {.filepath} to synchronize path", self) self.move(with_album=with_album) self.store() # Files themselves. def move_file(self, dest, operation=MoveOperation.MOVE): """Move, copy, link or hardlink the item depending on `operation`, updating the path value if the move succeeds. If a file exists at `dest`, then it is slightly modified to be unique. `operation` should be an instance of `util.MoveOperation`. """ if not util.samefile(self.path, dest): dest = util.unique_path(dest) if operation == MoveOperation.MOVE: plugins.send( "before_item_moved", item=self, source=self.path, destination=dest, ) util.move(self.path, dest) plugins.send( "item_moved", item=self, source=self.path, destination=dest ) elif operation == MoveOperation.COPY: util.copy(self.path, dest) plugins.send( "item_copied", item=self, source=self.path, destination=dest ) elif operation == MoveOperation.LINK: util.link(self.path, dest) plugins.send( "item_linked", item=self, source=self.path, destination=dest ) elif operation == MoveOperation.HARDLINK: util.hardlink(self.path, dest) plugins.send( "item_hardlinked", item=self, source=self.path, destination=dest ) elif operation == MoveOperation.REFLINK: util.reflink(self.path, dest, fallback=False) plugins.send( "item_reflinked", item=self, source=self.path, destination=dest ) elif operation == MoveOperation.REFLINK_AUTO: util.reflink(self.path, dest, fallback=True) plugins.send( "item_reflinked", item=self, source=self.path, destination=dest ) else: assert False, "unknown MoveOperation" # Either copying or moving succeeded, so update the stored path. self.path = dest def current_mtime(self): """Return the current mtime of the file, rounded to the nearest integer. """ return int(os.path.getmtime(syspath(self.path))) def try_filesize(self): """Get the size of the underlying file in bytes. If the file is missing, return 0 (and log a warning). """ try: return os.path.getsize(syspath(self.path)) except (OSError, Exception) as exc: log.warning("could not get filesize: {}", exc) return 0 # Model methods. def remove(self, delete=False, with_album=True): """Remove the item. If `delete`, then the associated file is removed from disk. If `with_album`, then the item's album (if any) is removed if the item was the last in the album. """ super().remove() # Remove the album if it is empty. if with_album: album = self.get_album() if album and not album.items(): album.remove(delete, False) # Send a 'item_removed' signal to plugins plugins.send("item_removed", item=self) # Delete the associated file. if delete: util.remove(self.path) util.prune_dirs(os.path.dirname(self.path), self._db.directory) self._db._memotable = {} def move( self, operation=MoveOperation.MOVE, basedir=None, with_album=True, store=True, ): """Move the item to its designated location within the library directory (provided by destination()). Subdirectories are created as needed. If the operation succeeds, the item's path field is updated to reflect the new location. Instead of moving the item it can also be copied, linked or hardlinked depending on `operation` which should be an instance of `util.MoveOperation`. `basedir` overrides the library base directory for the destination. If the item is in an album and `with_album` is `True`, the album is given an opportunity to move its art. By default, the item is stored to the database if it is in the database, so any dirty fields prior to the move() call will be written as a side effect. If `store` is `False` however, the item won't be stored and it will have to be manually stored after invoking this method. """ dest = self.destination(basedir=basedir) # Create necessary ancestry for the move. util.mkdirall(dest) # Perform the move and store the change. old_path = self.path self.move_file(dest, operation) if store: self.store() # If this item is in an album, move its art. if with_album: album = self.get_album() if album: album.move_art(operation) if store: album.store() # Prune vacated directory. if operation == MoveOperation.MOVE: util.prune_dirs(os.path.dirname(old_path), self._db.directory) # Templating. def destination( self, relative_to_libdir=False, basedir=None, path_formats=None, ) -> bytes: """Return the path in the library directory designated for the item (i.e., where the file ought to be). The path is returned as a bytestring. ``basedir`` can override the library's base directory for the destination. If ``relative_to_libdir`` is true, returns just the fragment of the path underneath the library base directory. """ basedir = basedir or self.db.directory path_formats = path_formats or self.db.path_formats # Use a path format based on a query, falling back on the # default. for query, path_format in path_formats: if query == PF_KEY_DEFAULT: continue query, _ = parse_query_string(query, type(self)) if query.match(self): # The query matches the item! Use the corresponding path # format. break else: # No query matched; fall back to default. for query, path_format in path_formats: if query == PF_KEY_DEFAULT: break else: assert False, "no default path format" if isinstance(path_format, Template): subpath_tmpl = path_format else: subpath_tmpl = template(path_format) # Evaluate the selected template. subpath = self.evaluate_template(subpath_tmpl, True) # Prepare path for output: normalize Unicode characters. if sys.platform == "darwin": subpath = unicodedata.normalize("NFD", subpath) else: subpath = unicodedata.normalize("NFC", subpath) if beets.config["asciify_paths"]: subpath = util.asciify_path( subpath, beets.config["path_sep_replace"].as_str() ) lib_path_str, fallback = util.legalize_path( subpath, self.db.replacements, self.filepath.suffix ) if fallback: # Print an error message if legalization fell back to # default replacements because of the maximum length. log.warning( "Fell back to default replacements when naming " "file {}. Configure replacements to avoid lengthening " "the filename.", subpath, ) lib_path_bytes = util.bytestring_path(lib_path_str) if relative_to_libdir: return lib_path_bytes return normpath(os.path.join(basedir, lib_path_bytes)) def _int_arg(s): """Convert a string argument to an integer for use in a template function. May raise a ValueError. """ return int(s.strip()) class DefaultTemplateFunctions: """A container class for the default functions provided to path templates. These functions are contained in an object to provide additional context to the functions -- specifically, the Item being evaluated. """ _prefix = "tmpl_" @cached_classproperty def _func_names(cls) -> list[str]: """Names of tmpl_* functions in this class.""" return [s for s in dir(cls) if s.startswith(cls._prefix)] def __init__(self, item=None, lib=None): """Parametrize the functions. If `item` or `lib` is None, then some functions (namely, ``aunique``) will always evaluate to the empty string. """ self.item = item self.lib = lib def functions(self): """Return a dictionary containing the functions defined in this object. The keys are function names (as exposed in templates) and the values are Python functions. """ out = {} for key in self._func_names: out[key[len(self._prefix) :]] = getattr(self, key) return out @staticmethod def tmpl_lower(s): """Convert a string to lower case.""" return s.lower() @staticmethod def tmpl_upper(s): """Convert a string to upper case.""" return s.upper() @staticmethod def tmpl_capitalize(s): """Converts to a capitalized string.""" return s.capitalize() @staticmethod def tmpl_title(s): """Convert a string to title case.""" return string.capwords(s) @staticmethod def tmpl_left(s, chars): """Get the leftmost characters of a string.""" return s[0 : _int_arg(chars)] @staticmethod def tmpl_right(s, chars): """Get the rightmost characters of a string.""" return s[-_int_arg(chars) :] @staticmethod def tmpl_if(condition, trueval, falseval=""): """If ``condition`` is nonempty and nonzero, emit ``trueval``; otherwise, emit ``falseval`` (if provided). """ try: int_condition = _int_arg(condition) except ValueError: if condition.lower() == "false": return falseval else: condition = int_condition if condition: return trueval else: return falseval @staticmethod def tmpl_asciify(s): """Translate non-ASCII characters to their ASCII equivalents.""" return util.asciify_path(s, beets.config["path_sep_replace"].as_str()) @staticmethod def tmpl_time(s, fmt): """Format a time value using `strftime`.""" cur_fmt = beets.config["time_format"].as_str() return time.strftime(fmt, time.strptime(s, cur_fmt)) def tmpl_aunique(self, keys=None, disam=None, bracket=None): """Generate a string that is guaranteed to be unique among all albums in the library who share the same set of keys. A fields from "disam" is used in the string if one is sufficient to disambiguate the albums. Otherwise, a fallback opaque value is used. Both "keys" and "disam" should be given as whitespace-separated lists of field names, while "bracket" is a pair of characters to be used as brackets surrounding the disambiguator or empty to have no brackets. """ # Fast paths: no album, no item or library, or memoized value. if not self.item or not self.lib: return "" if isinstance(self.item, Item): album_id = self.item.album_id elif isinstance(self.item, Album): album_id = self.item.id if album_id is None: return "" memokey = self._tmpl_unique_memokey("aunique", keys, disam, album_id) memoval = self.lib._memotable.get(memokey) if memoval is not None: return memoval album = self.lib.get_album(album_id) return self._tmpl_unique( "aunique", keys, disam, bracket, album_id, album, album.item_keys, # Do nothing for singletons. lambda a: a is None, ) def tmpl_sunique(self, keys=None, disam=None, bracket=None): """Generate a string that is guaranteed to be unique among all singletons in the library who share the same set of keys. A fields from "disam" is used in the string if one is sufficient to disambiguate the albums. Otherwise, a fallback opaque value is used. Both "keys" and "disam" should be given as whitespace-separated lists of field names, while "bracket" is a pair of characters to be used as brackets surrounding the disambiguator or empty to have no brackets. """ # Fast paths: no album, no item or library, or memoized value. if not self.item or not self.lib: return "" if isinstance(self.item, Item): item_id = self.item.id else: raise NotImplementedError("sunique is only implemented for items") if item_id is None: return "" return self._tmpl_unique( "sunique", keys, disam, bracket, item_id, self.item, Item.all_keys(), # Do nothing for non singletons. lambda i: i.album_id is not None, ) def _tmpl_unique_memokey(self, name, keys, disam, item_id): """Get the memokey for the unique template named "name" for the specific parameters. """ return (name, keys, disam, item_id) def _tmpl_unique( self, name, keys, disam, bracket, item_id, db_item, item_keys, skip_item, ): """Generate a string that is guaranteed to be unique among all items of the same type as "db_item" who share the same set of keys. A field from "disam" is used in the string if one is sufficient to disambiguate the items. Otherwise, a fallback opaque value is used. Both "keys" and "disam" should be given as whitespace-separated lists of field names, while "bracket" is a pair of characters to be used as brackets surrounding the disambiguator or empty to have no brackets. "name" is the name of the templates. It is also the name of the configuration section where the default values of the parameters are stored. "skip_item" is a function that must return True when the template should return an empty string. "initial_subqueries" is a list of subqueries that should be included in the query to find the ambiguous items. """ memokey = self._tmpl_unique_memokey(name, keys, disam, item_id) memoval = self.lib._memotable.get(memokey) if memoval is not None: return memoval if skip_item(db_item): self.lib._memotable[memokey] = "" return "" keys = keys or beets.config[name]["keys"].as_str() disam = disam or beets.config[name]["disambiguators"].as_str() if bracket is None: bracket = beets.config[name]["bracket"].as_str() keys = keys.split() disam = disam.split() # Assign a left and right bracket or leave blank if argument is empty. if len(bracket) == 2: bracket_l = bracket[0] bracket_r = bracket[1] else: bracket_l = "" bracket_r = "" # Find matching items to disambiguate with. query = db_item.duplicates_query(keys) ambigous_items = ( self.lib.items(query) if isinstance(db_item, Item) else self.lib.albums(query) ) # If there's only one item to matching these details, then do # nothing. if len(ambigous_items) == 1: self.lib._memotable[memokey] = "" return "" # Find the first disambiguator that distinguishes the items. for disambiguator in disam: # Get the value for each item for the current field. disam_values = {s.get(disambiguator, "") for s in ambigous_items} # If the set of unique values is equal to the number of # items in the disambiguation set, we're done -- this is # sufficient disambiguation. if len(disam_values) == len(ambigous_items): break else: # No disambiguator distinguished all fields. res = f" {bracket_l}{item_id}{bracket_r}" self.lib._memotable[memokey] = res return res # Flatten disambiguation value into a string. disam_value = db_item.formatted(for_path=True).get(disambiguator) # Return empty string if disambiguator is empty. if disam_value: res = f" {bracket_l}{disam_value}{bracket_r}" else: res = "" self.lib._memotable[memokey] = res return res @staticmethod def tmpl_first(s, count=1, skip=0, sep="; ", join_str="; "): """Get the item(s) from x to y in a string separated by something and join then with something. Args: s: the string count: The number of items included skip: The number of items skipped sep: the separator join_str: the string which will join the items """ skip = int(skip) count = skip + int(count) return join_str.join(s.split(sep)[skip:count]) def tmpl_ifdef(self, field, trueval="", falseval=""): """If field exists return trueval or the field (default) otherwise, emit return falseval (if provided). Args: field: The name of the field trueval: The string if the condition is true falseval: The string if the condition is false Returns: The string, based on condition. """ if field in self.item: return trueval if trueval else self.item.formatted().get(field) else: return falseval ================================================ FILE: beets/library/queries.py ================================================ from __future__ import annotations import shlex import beets from beets import dbcore, logging, plugins log = logging.getLogger("beets") # Special path format key. PF_KEY_DEFAULT = "default" # Query construction helpers. def parse_query_parts(parts, model_cls): """Given a beets query string as a list of components, return the `Query` and `Sort` they represent. Like `dbcore.parse_sorted_query`, with beets query prefixes and ensuring that implicit path queries are made explicit with 'path::' """ # Get query types and their prefix characters. prefixes = { ":": dbcore.query.RegexpQuery, "=~": dbcore.query.StringQuery, "=": dbcore.query.MatchQuery, } prefixes.update(plugins.queries()) # Special-case path-like queries, which are non-field queries # containing path separators (/). parts = [ f"path:{s}" if dbcore.query.PathQuery.is_path_query(s) else s for s in parts ] case_insensitive = beets.config["sort_case_insensitive"].get(bool) query, sort = dbcore.parse_sorted_query( model_cls, parts, prefixes, case_insensitive ) log.debug("Parsed query: {!r}", query) log.debug("Parsed sort: {!r}", sort) return query, sort def parse_query_string(s, model_cls): """Given a beets query string, return the `Query` and `Sort` they represent. The string is split into components using shell-like syntax. """ message = f"Query is not unicode: {s!r}" assert isinstance(s, str), message try: parts = shlex.split(s) except ValueError as exc: raise dbcore.InvalidQueryError(s, exc) return parse_query_parts(parts, model_cls) ================================================ FILE: beets/logging.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A drop-in replacement for the standard-library `logging` module. Provides everything the "logging" module does. In addition, beets' logger (as obtained by `getLogger(name)`) supports thread-local levels, and messages use {}-style formatting and can interpolate keywords arguments to the logging calls (`debug`, `info`, etc). """ from __future__ import annotations import re import threading from copy import copy from logging import ( DEBUG, INFO, NOTSET, WARNING, FileHandler, Filter, Handler, Logger, NullHandler, StreamHandler, ) from typing import TYPE_CHECKING, Any, TypeVar, overload if TYPE_CHECKING: from collections.abc import Mapping from logging import RootLogger from types import TracebackType T = TypeVar("T") # see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi _SysExcInfoType = ( tuple[type[BaseException], BaseException, TracebackType | None] | tuple[None, None, None] ) _ExcInfoType = _SysExcInfoType | BaseException | bool | None _ArgsType = tuple[object, ...] | Mapping[str, object] __all__ = [ "DEBUG", "INFO", "NOTSET", "WARNING", "FileHandler", "Filter", "Handler", "Logger", "NullHandler", "StreamHandler", "getLogger", ] # Regular expression to match: # - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r) # - DEL control character (0x7f) # - C1 control characters (0x80-0x9F) # Used to sanitize log messages that could disrupt terminal output _CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]") _UNICODE_REPLACEMENT_CHARACTER = "\ufffd" def _logsafe(val: T) -> str | T: """Coerce `bytes` to `str` to avoid crashes solely due to logging. This is particularly relevant for bytestring paths. Much of our code explicitly uses `displayable_path` for them, but better be safe and prevent any crashes that are solely due to log formatting. """ # Bytestring: Needs decoding to be safe for substitution in format strings. if isinstance(val, bytes): # Blindly convert with UTF-8. Eventually, it would be nice to # (a) only do this for paths, if they can be given a distinct # type, and (b) warn the developer if they do this for other # bytestrings. return val.decode("utf-8", "replace") if isinstance(val, str): # Sanitize log messages by replacing control characters that can disrupt # terminals. return _CONTROL_CHAR_REGEX.sub(_UNICODE_REPLACEMENT_CHARACTER, val) # Other objects are used as-is so field access, etc., still works in # the format string. Relies on a working __str__ implementation. return val class StrFormatLogger(Logger): """A version of `Logger` that uses `str.format`-style formatting instead of %-style formatting and supports keyword arguments. We cannot easily get rid of this even in the Python 3 era: This custom formatting supports substitution from `kwargs` into the message, which the default `logging.Logger._log()` implementation does not. Remark by @sampsyo: https://stackoverflow.com/a/24683360 might be a way to achieve this with less code. """ class _LogMessage: def __init__( self, msg: str, args: _ArgsType, kwargs: dict[str, Any], ): self.msg = msg self.args = args self.kwargs = kwargs def __str__(self): args = [_logsafe(a) for a in self.args] kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()} return self.msg.format(*args, **kwargs) def _log( self, level: int, msg: object, args: _ArgsType, exc_info: _ExcInfoType = None, extra: Mapping[str, Any] | None = None, stack_info: bool = False, stacklevel: int = 1, **kwargs, ): """Log msg.format(*args, **kwargs)""" if isinstance(msg, str): msg = self._LogMessage(msg, args, kwargs) return super()._log( level, msg, (), exc_info=exc_info, extra=extra, stack_info=stack_info, stacklevel=stacklevel, ) class ThreadLocalLevelLogger(Logger): """A version of `Logger` whose level is thread-local instead of shared.""" def __init__(self, name, level=NOTSET): self._thread_level = threading.local() self.default_level = NOTSET super().__init__(name, level) @property def level(self): try: return self._thread_level.level except AttributeError: self._thread_level.level = self.default_level return self.level @level.setter def level(self, value): self._thread_level.level = value def set_global_level(self, level): """Set the level on the current thread + the default value for all threads. """ self.default_level = level self.setLevel(level) class BeetsLogger(ThreadLocalLevelLogger, StrFormatLogger): """The logger class used by beets.""" def extra_debug(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a message at DEBUG level only when verbosity level is >= 3. Intended for high-verbosity tuning/diagnostic messages that would be too noisy at normal debug level. """ # Lazy import to avoid circular dependency (beets.__init__ -> beets.logging) from beets import config if config["verbose"].as_number() >= 3: self._log(DEBUG, msg, args, **kwargs) my_manager = copy(Logger.manager) my_manager.loggerClass = BeetsLogger @overload def getLogger(name: str) -> BeetsLogger: ... @overload def getLogger(name: None = ...) -> RootLogger: ... def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802 if name: return my_manager.getLogger(name) # type: ignore[return-value] else: return Logger.root ================================================ FILE: beets/mediafile.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import mediafile from .util.deprecation import deprecate_for_maintainers deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2) # Import everything from the mediafile module into this module. for key, value in mediafile.__dict__.items(): if key not in ["__name__"]: globals()[key] = value # Cleanup namespace. del key, value, mediafile ================================================ FILE: beets/metadata_plugins.py ================================================ """Metadata source plugin interface. This allows beets to lookup metadata from various sources. We define a common interface for all metadata sources which need to be implemented as plugins. """ from __future__ import annotations import abc import re from contextlib import contextmanager from functools import cache, cached_property, wraps from typing import ( TYPE_CHECKING, Generic, Literal, NamedTuple, TypedDict, TypeVar, ) import unidecode from confuse import NotFoundError from beets import config, logging from beets.util import cached_classproperty from beets.util.id_extractors import extract_release_id from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send Ret = TypeVar("Ret") QueryType = Literal["album", "track"] if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence from .autotag.hooks import AlbumInfo, Item, TrackInfo # Global logger. log = logging.getLogger("beets") @cache def find_metadata_source_plugins() -> list[MetadataSourcePlugin]: """Return a list of all loaded metadata source plugins.""" # TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0 # This should also allow us to remove the type: ignore comments below. return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc] @cache def get_metadata_source(name: str) -> MetadataSourcePlugin | None: """Get metadata source plugin by name.""" name = name.lower() plugins = find_metadata_source_plugins() return next((p for p in plugins if p.data_source.lower() == name), None) @contextmanager def maybe_handle_plugin_error(plugin: MetadataSourcePlugin, method_name: str): """Safely call a plugin method, catching and logging exceptions.""" if config["raise_on_error"]: yield else: try: yield except Exception as e: log.error( "Error in '{}.{}': {}", plugin.data_source, method_name, e ) log.debug("Exception details:", exc_info=True) def _yield_from_plugins( func: Callable[..., Iterable[Ret]], ) -> Callable[..., Iterator[Ret]]: method_name = func.__name__ @wraps(func) def wrapper(*args, **kwargs) -> Iterator[Ret]: for plugin in find_metadata_source_plugins(): method = getattr(plugin, method_name) with maybe_handle_plugin_error(plugin, method_name): yield from filter(None, method(*args, **kwargs)) return wrapper @notify_info_yielded("albuminfo_received") @_yield_from_plugins def candidates(*args, **kwargs) -> Iterator[AlbumInfo]: yield from () @notify_info_yielded("trackinfo_received") @_yield_from_plugins def item_candidates(*args, **kwargs) -> Iterator[TrackInfo]: yield from () @notify_info_yielded("albuminfo_received") @_yield_from_plugins def albums_for_ids(*args, **kwargs) -> Iterator[AlbumInfo]: yield from () @notify_info_yielded("trackinfo_received") @_yield_from_plugins def tracks_for_ids(*args, **kwargs) -> Iterator[TrackInfo]: yield from () def album_for_id(_id: str, data_source: str) -> AlbumInfo | None: """Get AlbumInfo object for the given ID and data source.""" if plugin := get_metadata_source(data_source): with maybe_handle_plugin_error(plugin, "album_for_id"): if info := plugin.album_for_id(_id): send("albuminfo_received", info=info) return info return None def track_for_id(_id: str, data_source: str) -> TrackInfo | None: """Get TrackInfo object for the given ID and data source.""" if plugin := get_metadata_source(data_source): with maybe_handle_plugin_error(plugin, "track_for_id"): if info := plugin.track_for_id(_id): send("trackinfo_received", info=info) return info return None @cache def get_penalty(data_source: str | None) -> float: """Get the penalty value for the given data source.""" return next( ( p.data_source_mismatch_penalty for p in find_metadata_source_plugins() if p.data_source == data_source ), MetadataSourcePlugin.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, ) class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta): """A plugin that provides metadata from a specific source. This base class implements a contract for plugins that provide metadata from a specific source. The plugin must implement the methods to search for albums and tracks, and to retrieve album and track information by ID. """ DEFAULT_DATA_SOURCE_MISMATCH_PENALTY = 0.5 @cached_classproperty def data_source(cls) -> str: """The data source name for this plugin. This is inferred from the plugin name. """ return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined] @cached_property def data_source_mismatch_penalty(self) -> float: try: return self.config["source_weight"].as_number() except NotFoundError: return self.config["data_source_mismatch_penalty"].as_number() def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.config.add( { "search_limit": 5, "data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501 } ) @abc.abstractmethod def album_for_id(self, album_id: str) -> AlbumInfo | None: """Return :py:class:`AlbumInfo` object or None if no matching release was found.""" raise NotImplementedError @abc.abstractmethod def track_for_id(self, track_id: str) -> TrackInfo | None: """Return a :py:class:`TrackInfo` object or None if no matching release was found. """ raise NotImplementedError # ---------------------------------- search ---------------------------------- # @abc.abstractmethod def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: """Return :py:class:`AlbumInfo` candidates that match the given album. Used in the autotag functionality to search for albums. :param items: List of items in the album :param artist: Album artist :param album: Album name :param va_likely: Whether the album is likely to be by various artists """ raise NotImplementedError @abc.abstractmethod def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: """Return :py:class:`TrackInfo` candidates that match the given track. Used in the autotag functionality to search for tracks. :param item: Track item :param artist: Track artist :param title: Track title """ raise NotImplementedError def albums_for_ids(self, ids: Iterable[str]) -> Iterable[AlbumInfo | None]: """Batch lookup of album metadata for a list of album IDs. Given a list of album identifiers, yields corresponding AlbumInfo objects. Missing albums result in None values in the output iterator. Plugins may implement this for optimized batched lookups instead of single calls to album_for_id. """ return (self.album_for_id(id) for id in ids) def tracks_for_ids(self, ids: Iterable[str]) -> Iterable[TrackInfo | None]: """Batch lookup of track metadata for a list of track IDs. Given a list of track identifiers, yields corresponding TrackInfo objects. Missing tracks result in None values in the output iterator. Plugins may implement this for optimized batched lookups instead of single calls to track_for_id. """ return (self.track_for_id(id) for id in ids) def _extract_id(self, url: str) -> str | None: """Extract an ID from a URL for this metadata source plugin. Uses the plugin's data source name to determine the ID format and extracts the ID from a given URL. """ return extract_release_id(self.data_source, url) @staticmethod def get_artist( artists: Iterable[dict[str | int, str]], id_key: str | int = "id", name_key: str | int = "name", join_key: str | int | None = None, ) -> tuple[str, str | None]: """Returns an artist string (all artists) and an artist_id (the main artist) for a list of artist object dicts. For each artist, this function moves articles (such as 'a', 'an', and 'the') to the front. It returns a tuple containing the comma-separated string of all normalized artists and the ``id`` of the main/first artist. Alternatively a keyword can be used to combine artists together into a single string by passing the join_key argument. :param artists: Iterable of artist dicts or lists returned by API. :param id_key: Key or index corresponding to the value of ``id`` for the main/first artist. Defaults to 'id'. :param name_key: Key or index corresponding to values of names to concatenate for the artist string (containing all artists). Defaults to 'name'. :param join_key: Key or index corresponding to a field containing a keyword to use for combining artists into a single string, for example "Feat.", "Vs.", "And" or similar. The default is None which keeps the default behaviour (comma-separated). :return: Normalized artist string. """ artist_id = None artist_string = "" artists = list(artists) # In case a generator was passed. total = len(artists) for idx, artist in enumerate(artists): if not artist_id: artist_id = artist[id_key] name = artist[name_key] # Move articles to the front. name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I) # Use a join keyword if requested and available. if idx < (total - 1): # Skip joining on last. if join_key and artist.get(join_key, None): name += f" {artist[join_key]} " else: name += ", " artist_string += name return artist_string, artist_id class IDResponse(TypedDict): """Response from the API containing an ID.""" id: str class SearchParams(NamedTuple): """Bundle normalized search context passed to provider search hooks. Shared search orchestration constructs this value so plugin hooks receive one object describing search intent, query text, and provider filters. """ query_type: QueryType query: str filters: dict[str, str] limit: int R = TypeVar("R", bound=IDResponse) class SearchApiMetadataSourcePlugin( Generic[R], MetadataSourcePlugin, metaclass=abc.ABCMeta ): """Helper class to implement a metadata source plugin with an API. Plugins using this ABC must implement an API search method to retrieve album and track information by ID, i.e. `album_for_id` and `track_for_id`, and a search method to perform a search on the API. The search method should return a list of identifiers for the requested type (album or track). """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.config.add( { "search_query_ascii": False, } ) @abc.abstractmethod def get_search_query_with_filters( self, query_type: QueryType, items: Sequence[Item], artist: str, name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: """Build query text and API filters for a provider search. Subclasses can override this hook when their API requires a query format or filter set that differs from the default text-based construction. :param query_type: The type of query to perform. Either *album* or *track* :param items: List of items the search is being performed for :param artist: Artist name :param name: Album or track name, depending on ``query_type`` :param va_likely: Whether the search is likely to be for various artists :return: Tuple of (``query`` text, ``filters`` dict) to use for the search API call """ @abc.abstractmethod def get_search_response(self, params: SearchParams) -> Sequence[R]: """Fetch raw search results for a provider request. Implementations should return records containing source IDs so shared candidate resolution can perform ID-based album and track lookups. :param params: :py:namedtuple:`~SearchParams` named tuple :return: Sequence of IDResponse dicts containing at least an "id" key for each """ raise NotImplementedError def _search_api( self, query_type: QueryType, query: str, filters: dict[str, str] ) -> Sequence[R]: """Run shared provider search orchestration and return ID-bearing results. This path applies optional query normalization and default limits, then delegates API access to provider hooks with consistent logging and failure handling. """ if self.config["search_query_ascii"].get(): query = unidecode.unidecode(query) limit = self.config["search_limit"].get(int) params = SearchParams(query_type, query, filters, limit) self._log.debug("Searching for '{}' with {}", query, filters) try: response_data = self.get_search_response(params) except Exception as e: if config["raise_on_error"].get(bool): raise self._log.error( "Error searching {.data_source}: {}", self, e, exc_info=True ) return () self._log.debug("Found {} result(s)", len(response_data)) return response_data def _get_candidates( self, query_type: QueryType, *args, **kwargs ) -> Sequence[R]: """Resolve query hooks and execute one provider search request.""" return self._search_api( query_type, *self.get_search_query_with_filters(query_type, *args, **kwargs), ) def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: results = self._get_candidates("album", items, artist, album, va_likely) return filter(None, self.albums_for_ids(r["id"] for r in results)) def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: results = self._get_candidates("track", [item], artist, title, False) return filter(None, self.tracks_for_ids(r["id"] for r in results)) ================================================ FILE: beets/plugins.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Support for beets plugins.""" from __future__ import annotations import abc import inspect import re import sys from collections import defaultdict from functools import cached_property, wraps from importlib import import_module from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar import mediafile from typing_extensions import ParamSpec import beets from beets import logging from beets.util import unique_list from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence from confuse import Subview from beets.dbcore import Query from beets.dbcore.db import FieldQueryType from beets.dbcore.types import Type from beets.importer import ImportSession, ImportTask from beets.library import Album, Item, Library from beets.ui import Subcommand # TYPE_CHECKING guard is needed for any derived type # which uses an import from `beets.library` and `beets.imported` ImportStageFunc = Callable[[ImportSession, ImportTask], None] T = TypeVar("T", Album, Item, str) TFunc = Callable[[T], str] TFuncMap = dict[str, TFunc[T]] AnyModel = TypeVar("AnyModel", Album, Item) P = ParamSpec("P") Ret = TypeVar("Ret", bound=Any) Listener = Callable[..., Any] PLUGIN_NAMESPACE = "beetsplug" # Plugins using the Last.fm API can share the same API key. LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43" EventType = Literal[ "after_write", "album_imported", "album_removed", "albuminfo_received", "album_matched", "before_choose_candidate", "before_item_moved", "cli_exit", "database_change", "import", "import_begin", "import_task_apply", "import_task_before_choice", "import_task_choice", "import_task_created", "import_task_files", "import_task_start", "item_copied", "item_hardlinked", "item_imported", "item_linked", "item_moved", "item_reflinked", "item_removed", "library_opened", "mb_album_extract", "mb_track_extract", "pluginload", "trackinfo_received", "write", ] # Global logger. log = logging.getLogger("beets") class PluginConflictError(Exception): """Indicates that the services provided by one plugin conflict with those of another. For example two plugins may define different types for flexible fields. """ class PluginImportError(ImportError): """Indicates that a plugin could not be imported. This is a subclass of ImportError so that it can be caught separately from other errors. """ def __init__(self, name: str): super().__init__(f"Could not import plugin {name}") class PluginLogFilter(logging.Filter): """A logging filter that identifies the plugin that emitted a log message. """ def __init__(self, plugin): self.prefix = f"{plugin.name}: " def filter(self, record): if hasattr(record.msg, "msg") and isinstance(record.msg.msg, str): # A _LogMessage from our hacked-up Logging replacement. record.msg.msg = f"{self.prefix}{record.msg.msg}" elif isinstance(record.msg, str): record.msg = f"{self.prefix}{record.msg}" return True # Managing the plugins themselves. class BeetsPluginMeta(abc.ABCMeta): template_funcs: ClassVar[TFuncMap[str]] = {} template_fields: ClassVar[TFuncMap[Item]] = {} album_template_fields: ClassVar[TFuncMap[Album]] = {} class BeetsPlugin(metaclass=BeetsPluginMeta): """The base class for all beets plugins. Plugins provide functionality by defining a subclass of BeetsPlugin and overriding the abstract methods defined here. """ _raw_listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict( list ) listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list) template_funcs: TFuncMap[str] template_fields: TFuncMap[Item] album_template_fields: TFuncMap[Album] name: str config: Subview early_import_stages: list[ImportStageFunc] import_stages: list[ImportStageFunc] def __init_subclass__(cls) -> None: """Enable legacy metadata source plugins to work with the new interface. When a plugin subclass of BeetsPlugin defines a `data_source` attribute but does not inherit from MetadataSourcePlugin, this hook: 1. Skips abstract classes. 2. Warns that the class should extend MetadataSourcePlugin (deprecation). 3. Copies any nonabstract methods from MetadataSourcePlugin onto the subclass to provide the full plugin API. This compatibility layer will be removed in the v3.0.0 release. """ # TODO: Remove in v3.0.0 if inspect.isabstract(cls): return from beets.metadata_plugins import MetadataSourcePlugin if issubclass(cls, MetadataSourcePlugin) or not hasattr( cls, "data_source" ): return deprecate_for_maintainers( ( f"'{cls.__name__}' is used as a legacy metadata source since it" " inherits 'beets.plugins.BeetsPlugin'. Support for this" ), "'beets.metadata_plugins.MetadataSourcePlugin'", stacklevel=3, ) method: property | cached_property[Any] | Callable[..., Any] for name, method in inspect.getmembers( MetadataSourcePlugin, predicate=lambda f: ( # type: ignore[arg-type] ( isinstance(f, (property, cached_property)) and not hasattr( BeetsPlugin, getattr(f, "attrname", None) or f.fget.__name__, # type: ignore[union-attr] ) ) or ( inspect.isfunction(f) and f.__name__ and not getattr(f, "__isabstractmethod__", False) and not hasattr(BeetsPlugin, f.__name__) ) ), ): setattr(cls, name, method) def __init__(self, name: str | None = None): """Perform one-time plugin setup.""" self.name = name or self.__module__.split(".")[-1] self.config = beets.config[self.name] # create per-instance storage for template fields and functions self.template_funcs = {} self.template_fields = {} self.album_template_fields = {} self.early_import_stages = [] self.import_stages = [] self._log = log.getChild(self.name) self._log.setLevel(logging.NOTSET) # Use `beets` logger level. if not any(isinstance(f, PluginLogFilter) for f in self._log.filters): self._log.addFilter(PluginLogFilter(self)) # In order to verify the config we need to make sure the plugin is fully # configured (plugins usually add the default configuration *after* # calling super().__init__()). self.register_listener("pluginload", self._verify_config) def _verify_config(self, *_, **__) -> None: """Verify plugin configuration. If deprecated 'source_weight' option is explicitly set by the user, they will see a warning in the logs. Otherwise, this must be configured by a third party plugin, thus we raise a deprecation warning which won't be shown to user but will be visible to plugin developers. """ # TODO: Remove in v3.0.0 if ( not hasattr(self, "data_source") or "source_weight" not in self.config ): return for source in self.config.root().sources: if "source_weight" in (source.get(self.name) or {}): if source.filename: # user config deprecate_for_user( self._log, f"'{self.name}.source_weight' configuration option", f"'{self.name}.data_source_mismatch_penalty'", ) else: # 3rd-party plugin config deprecate_for_maintainers( "'source_weight' configuration option", "'data_source_mismatch_penalty'", ) def commands(self) -> Sequence[Subcommand]: """Should return a list of beets.ui.Subcommand objects for commands that should be added to beets' CLI. """ return () def _set_stage_log_level( self, stages: list[ImportStageFunc], ) -> list[ImportStageFunc]: """Adjust all the stages in `stages` to WARNING logging level.""" return [ self._set_log_level_and_params(logging.WARNING, stage) for stage in stages ] def get_early_import_stages(self) -> list[ImportStageFunc]: """Return a list of functions that should be called as importer pipelines stages early in the pipeline. The callables are wrapped versions of the functions in `self.early_import_stages`. Wrapping provides some bookkeeping for the plugin: specifically, the logging level is adjusted to WARNING. """ return self._set_stage_log_level(self.early_import_stages) def get_import_stages(self) -> list[ImportStageFunc]: """Return a list of functions that should be called as importer pipelines stages. The callables are wrapped versions of the functions in `self.import_stages`. Wrapping provides some bookkeeping for the plugin: specifically, the logging level is adjusted to WARNING. """ return self._set_stage_log_level(self.import_stages) def _set_log_level_and_params( self, base_log_level: int, func: Callable[P, Ret], ) -> Callable[P, Ret]: """Wrap `func` to temporarily set this plugin's logger level to `base_log_level` + config options (and restore it to its previous value after the function returns). Also determines which params may not be sent for backwards-compatibility. """ argspec = inspect.getfullargspec(func) @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> Ret: assert self._log.level == logging.NOTSET verbosity = beets.config["verbose"].get(int) log_level = max(logging.DEBUG, base_log_level - 10 * verbosity) self._log.setLevel(log_level) if argspec.varkw is None: kwargs = {k: v for k, v in kwargs.items() if k in argspec.args} # type: ignore[assignment] try: return func(*args, **kwargs) finally: self._log.setLevel(logging.NOTSET) return wrapper def queries(self) -> dict[str, type[Query]]: """Return a dict mapping prefixes to Query subclasses.""" return {} def add_media_field( self, name: str, descriptor: mediafile.MediaField ) -> None: """Add a field that is synchronized between media files and items. When a media field is added ``item.write()`` will set the name property of the item's MediaFile to ``item[name]`` and save the changes. Similarly ``item.read()`` will set ``item[name]`` to the value of the name property of the media file. """ # Defer import to prevent circular dependency from beets import library mediafile.MediaFile.add_field(name, descriptor) library.Item._media_fields.add(name) def register_listener(self, event: EventType, func: Listener) -> None: """Add a function as a listener for the specified event.""" if func not in self._raw_listeners[event]: self._raw_listeners[event].append(func) self.listeners[event].append( self._set_log_level_and_params(logging.WARNING, func) ) @classmethod def template_func(cls, name: str) -> Callable[[TFunc[str]], TFunc[str]]: """Decorator that registers a path template function. The function will be invoked as ``%name{}`` from path format strings. """ def helper(func: TFunc[str]) -> TFunc[str]: cls.template_funcs[name] = func return func return helper @classmethod def template_field(cls, name: str) -> Callable[[TFunc[Item]], TFunc[Item]]: """Decorator that registers a path template field computation. The value will be referenced as ``$name`` from path format strings. The function must accept a single parameter, the Item being formatted. """ def helper(func: TFunc[Item]) -> TFunc[Item]: cls.template_fields[name] = func return func return helper def get_plugin_names() -> list[str]: """Discover and return the set of plugin names to be loaded. Configures the plugin search paths and resolves the final set of plugins based on configuration settings, inclusion filters, and exclusion rules. Automatically includes the musicbrainz plugin when enabled in configuration. """ paths = [ str(Path(p).expanduser().absolute()) for p in beets.config["pluginpath"].as_str_seq(split=False) ] log.debug("plugin paths: {}", paths) # Extend the `beetsplug` package to include the plugin paths. import beetsplug beetsplug.__path__ = paths + list(beetsplug.__path__) # For backwards compatibility, also support plugin paths that # *contain* a `beetsplug` package. sys.path += paths plugins = unique_list(beets.config["plugins"].as_str_seq()) beets.config.add({"disabled_plugins": []}) disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq()) # TODO: Remove in v3.0.0 mb_enabled = beets.config["musicbrainz"].flatten().get("enabled") if mb_enabled: deprecate_for_user( log, "'musicbrainz.enabled' configuration option", "'plugins' configuration to explicitly add 'musicbrainz'", ) if "musicbrainz" not in plugins: plugins.append("musicbrainz") elif mb_enabled is False: deprecate_for_user(log, "'musicbrainz.enabled' configuration option") disabled_plugins.add("musicbrainz") return [p for p in plugins if p not in disabled_plugins] def _get_plugin(name: str) -> BeetsPlugin | None: """Dynamically load and instantiate a plugin class by name. Attempts to import the plugin module, locate the appropriate plugin class within it, and return an instance. Handles import failures gracefully and logs warnings for missing plugins or loading errors. Note we load the *last* plugin class found in the plugin namespace. This allows plugins to define helper classes that inherit from BeetsPlugin without those being loaded as the main plugin class. Returns None if the plugin could not be loaded for any reason. """ try: try: namespace = import_module(f"{PLUGIN_NAMESPACE}.{name}") except Exception as exc: raise PluginImportError(name) from exc for obj in reversed(namespace.__dict__.values()): if ( inspect.isclass(obj) and issubclass(obj, BeetsPlugin) and obj != BeetsPlugin and not inspect.isabstract(obj) # Only consider this plugin's module or submodules to avoid # conflicts when plugins import other BeetsPlugin classes and ( obj.__module__ == namespace.__name__ or obj.__module__.startswith(f"{namespace.__name__}.") ) ): return obj() except Exception: log.warning("** error loading plugin {}", name, exc_info=True) return None _instances: list[BeetsPlugin] = [] def load_plugins() -> None: """Initialize the plugin system by loading all configured plugins. Performs one-time plugin discovery and instantiation, storing loaded plugin instances globally. Emits a pluginload event after successful initialization to notify other components. """ if not _instances: names = get_plugin_names() log.debug("Loading plugins: {}", ", ".join(sorted(names))) _instances.extend(filter(None, map(_get_plugin, names))) send("pluginload") def find_plugins() -> Iterable[BeetsPlugin]: return _instances # Communication with plugins. def commands() -> list[Subcommand]: """Returns a list of Subcommand objects from all loaded plugins.""" out: list[Subcommand] = [] for plugin in find_plugins(): out += plugin.commands() return out def queries() -> dict[str, type[Query]]: """Returns a dict mapping prefix strings to Query subclasses all loaded plugins. """ out: dict[str, type[Query]] = {} for plugin in find_plugins(): out.update(plugin.queries()) return out def types(model_cls: type[AnyModel]) -> dict[str, Type]: """Return mapping between flex field names and types for the given model.""" attr_name = f"{model_cls.__name__.lower()}_types" types: dict[str, Type] = {} for plugin in find_plugins(): plugin_types = getattr(plugin, attr_name, {}) for field in plugin_types: if field in types and plugin_types[field] != types[field]: raise PluginConflictError( f"Plugin {plugin.name} defines flexible field {field} " "which has already been defined with " "another type." ) types.update(plugin_types) return types def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]: """Return mapping between field names and queries for the given model.""" attr_name = f"{model_cls.__name__.lower()}_queries" return { field: query for plugin in find_plugins() for field, query in getattr(plugin, attr_name, {}).items() } def notify_info_yielded( event: EventType, ) -> Callable[[Callable[P, Iterable[Ret]]], Callable[P, Iterator[Ret]]]: """Makes a generator send the event 'event' every time it yields. This decorator is supposed to decorate a generator, but any function returning an iterable should work. Each yielded value is passed to plugins using the 'info' parameter of 'send'. """ def decorator( func: Callable[P, Iterable[Ret]], ) -> Callable[P, Iterator[Ret]]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> Iterator[Ret]: for v in func(*args, **kwargs): send(event, info=v) yield v return wrapper return decorator def template_funcs() -> TFuncMap[str]: """Get all the template functions declared by plugins as a dictionary. """ funcs: TFuncMap[str] = {} for plugin in find_plugins(): funcs.update(plugin.template_funcs) return funcs def early_import_stages() -> list[ImportStageFunc]: """Get a list of early import stage functions defined by plugins.""" stages: list[ImportStageFunc] = [] for plugin in find_plugins(): stages += plugin.get_early_import_stages() return stages def import_stages() -> list[ImportStageFunc]: """Get a list of import stage functions defined by plugins.""" stages: list[ImportStageFunc] = [] for plugin in find_plugins(): stages += plugin.get_import_stages() return stages # New-style (lazy) plugin-provided fields. F = TypeVar("F") def _check_conflicts_and_merge( plugin: BeetsPlugin, plugin_funcs: dict[str, F], funcs: dict[str, F] ) -> None: """Check the provided template functions for conflicts and merge into funcs. Raises a `PluginConflictError` if a plugin defines template functions for fields that another plugin has already defined template functions for. """ if not plugin_funcs.keys().isdisjoint(funcs.keys()): conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys()) raise PluginConflictError( f"Plugin {plugin.name} defines template functions for " f"{conflicted_fields} that conflict with another plugin." ) funcs.update(plugin_funcs) def item_field_getters() -> TFuncMap[Item]: """Get a dictionary mapping field names to unary functions that compute the field's value. """ funcs: TFuncMap[Item] = {} for plugin in find_plugins(): _check_conflicts_and_merge(plugin, plugin.template_fields, funcs) return funcs def album_field_getters() -> TFuncMap[Album]: """As above, for album fields.""" funcs: TFuncMap[Album] = {} for plugin in find_plugins(): _check_conflicts_and_merge(plugin, plugin.album_template_fields, funcs) return funcs # Event dispatch. def send(event: EventType, **arguments: Any) -> list[Any]: """Send an event to all assigned event listeners. `event` is the name of the event to send, all other named arguments are passed along to the handlers. Return a list of non-None values returned from the handlers. """ log.debug("Sending event: {}", event) return [ r for handler in BeetsPlugin.listeners[event] if (r := handler(**arguments)) is not None ] def feat_tokens( for_artist: bool = True, custom_words: list[str] | None = None ) -> str: """Return a regular expression that matches phrases like "featuring" that separate a main artist or a song title from secondary artists. The `for_artist` option determines whether the regex should be suitable for matching artist fields (the default) or title fields. """ feat_words = ["ft", "featuring", "feat", "feat.", "ft."] if isinstance(custom_words, list): feat_words += custom_words if for_artist: feat_words += ["with", "vs", "and", "con", "&"] return ( rf"(?<=[\s(\[])(?:{'|'.join(re.escape(x) for x in feat_words)})(?=\s)" ) def apply_item_changes( lib: Library, item: Item, move: bool, pretend: bool, write: bool ) -> None: """Store, move, and write the item according to the arguments. :param lib: beets library. :param item: Item whose changes to apply. :param move: Move the item if it's in the library. :param pretend: Return without moving, writing, or storing the item's metadata. :param write: Write the item's metadata to its media file. """ if pretend: return from beets import util # Move the item if it's in the library. if move and lib.directory in util.ancestry(item.path): item.move(with_album=False) if write: item.try_write() item.store() ================================================ FILE: beets/py.typed ================================================ ================================================ FILE: beets/test/__init__.py ================================================ # This file is part of beets. # Copyright 2024, Lars Kruse # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module contains components of beets' test environment, which may be of use for testing procedures of external libraries or programs. For example the 'TestHelper' class may be useful for creating an in-memory beets library filled with a few example items. """ ================================================ FILE: beets/test/_common.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Some common functionality for beets' test cases.""" from __future__ import annotations import os import sys import unittest from contextlib import contextmanager from typing import TYPE_CHECKING import beets import beets.library # Make sure the development versions of the plugins are used import beetsplug from beets import importer, logging, util from beets.ui import commands from beets.util import syspath if TYPE_CHECKING: import pytest beetsplug.__path__ = [ os.path.abspath( os.path.join( os.path.dirname(__file__), os.path.pardir, os.path.pardir, "beetsplug", ) ) ] # Test resources path. RSRC = util.bytestring_path( os.path.abspath( os.path.join( os.path.dirname(__file__), os.path.pardir, os.path.pardir, "test", "rsrc", ) ) ) PLUGINPATH = os.path.join(RSRC.decode(), "beetsplug") # Propagate to root logger so the test runner can capture it log = logging.getLogger("beets") log.propagate = True log.setLevel(logging.DEBUG) # OS feature test. HAVE_SYMLINK = sys.platform != "win32" HAVE_HARDLINK = sys.platform != "win32" def item(lib=None, **kwargs): defaults = dict( title="the title", artist="the artist", albumartist="the album artist", album="the album", genres=["the genre"], lyricist="the lyricist", composer="the composer", arranger="the arranger", grouping="the grouping", work="the work title", mb_workid="the work musicbrainz id", work_disambig="the work disambiguation", year=1, month=2, day=3, track=4, tracktotal=5, disc=6, disctotal=7, lyrics="the lyrics", comments="the comments", bpm=8, comp=True, length=60.0, bitrate=128000, format="FLAC", mb_trackid="someID-1", mb_albumid="someID-2", mb_artistid="someID-3", mb_albumartistid="someID-4", mb_releasetrackid="someID-5", album_id=None, mtime=12345, ) i = beets.library.Item(**{**defaults, **kwargs}) if lib: lib.add(i) return i # Dummy import session. def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): cls = ( commands.import_.session.TerminalImportSession if cli else importer.ImportSession ) return cls(lib, loghandler, paths, query) # Mock I/O. class InputError(IOError): def __str__(self) -> str: return "Attempt to read with no input provided." class DummyIn: encoding = "utf-8" def __init__(self) -> None: self.buf: list[str] = [] def add(self, s: str) -> None: self.buf.append(f"{s}\n") def close(self) -> None: pass def readline(self) -> str: if not self.buf: raise InputError return self.buf.pop(0) class DummyIO: """Test helper that manages standard input and output.""" def __init__( self, monkeypatch: pytest.MonkeyPatch, capteesys: pytest.CaptureFixture[str], ) -> None: self._capteesys = capteesys self.stdin = DummyIn() monkeypatch.setattr("sys.stdin", self.stdin) def addinput(self, text: str) -> None: """Simulate user typing into stdin.""" self.stdin.add(text) def getoutput(self) -> str: """Get the standard output captured so far. Note: it clears the internal buffer, so subsequent calls will only return *new* output. """ # Using capteesys allows you to see output in the console if the test fails return self._capteesys.readouterr().out # Utility. def touch(path): open(syspath(path), "a").close() class Bag: """An object that exposes a set of fields given as keyword arguments. Any field not found in the dictionary appears to be None. Used for mocking Album objects and the like. """ def __init__(self, **fields): self.fields = fields def __getattr__(self, key): return self.fields.get(key) # Platform mocking. @contextmanager def platform_windows(): import ntpath old_path = os.path try: os.path = ntpath yield finally: os.path = old_path @contextmanager def platform_posix(): import posixpath old_path = os.path try: os.path = posixpath yield finally: os.path = old_path @contextmanager def system_mock(name): import platform old_system = platform.system platform.system = lambda: name try: yield finally: platform.system = old_system def slow_test(unused=None): def _id(obj): return obj if "SKIP_SLOW_TESTS" in os.environ: return unittest.skip("test is slow") return _id ================================================ FILE: beets/test/helper.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module includes various helpers that provide fixtures, capture information or mock the environment. - `has_program` checks the presence of a command on the system. - The `ImportSessionFixture` allows one to run importer code while controlling the interactions through code. - The `TestHelper` class encapsulates various fixtures that can be set up. """ from __future__ import annotations import os import os.path import shutil import subprocess import sys import unittest from contextlib import contextmanager from dataclasses import dataclass from enum import Enum from functools import cached_property from pathlib import Path from tempfile import gettempdir, mkdtemp, mkstemp from typing import Any, ClassVar from unittest.mock import patch import pytest import responses from mediafile import Image, MediaFile import beets import beets.plugins from beets import importer, logging, util from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.importer import ImportSession from beets.library import Item, Library from beets.test import _common from beets.ui.commands.import_.session import TerminalImportSession from beets.util import ( MoveOperation, bytestring_path, clean_module_tempdir, syspath, ) class LogCapture(logging.Handler): def __init__(self): logging.Handler.__init__(self) self.messages = [] def emit(self, record): self.messages.append(str(record.msg)) @contextmanager def capture_log(logger="beets"): capture = LogCapture() log = logging.getLogger(logger) log.addHandler(capture) try: yield capture.messages finally: log.removeHandler(capture) def has_program(cmd, args=["--version"]): """Returns `True` if `cmd` can be executed.""" full_cmd = [cmd, *args] try: with open(os.devnull, "wb") as devnull: subprocess.check_call( full_cmd, stderr=devnull, stdout=devnull, stdin=devnull ) except OSError: return False except subprocess.CalledProcessError: return False else: return True def check_reflink_support(path: str) -> bool: try: import reflink except ImportError: return False return reflink.supported_at(path) class ConfigMixin: @cached_property def config(self) -> beets.IncludeLazyConfig: """Base beets configuration for tests.""" config = beets.config config.sources = [] config.read(user=False, defaults=True) config["plugins"] = [] config["verbose"] = 1 config["ui"]["color"] = False config["threaded"] = False return config NEEDS_REFLINK = unittest.skipUnless( check_reflink_support(gettempdir()), "no reflink support for libdir" ) class RunMixin: def run_command(self, *args, **kwargs): """Run a beets command with an arbitrary amount of arguments. The Library` defaults to `self.lib`, but can be overridden with the keyword argument `lib`. """ sys.argv = ["beet"] # avoid leakage from test suite args lib = None if hasattr(self, "lib"): lib = self.lib lib = kwargs.get("lib", lib) beets.ui._raw_main(list(args), lib) @pytest.mark.usefixtures("io") class IOMixin(RunMixin): io: _common.DummyIO def run_with_output(self, *args): self.io.getoutput() self.run_command(*args) return self.io.getoutput() class TestHelper(RunMixin, ConfigMixin): """Helper mixin for high-level cli and plugin tests. This mixin provides methods to isolate beets' global state provide fixtures. """ lib: Library resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3" db_on_disk: ClassVar[bool] = False @cached_property def temp_dir_path(self) -> Path: return Path(self.create_temp_dir()) @cached_property def temp_dir(self) -> bytes: return util.bytestring_path(self.temp_dir_path) @cached_property def lib_path(self) -> Path: lib_path = self.temp_dir_path / "libdir" lib_path.mkdir(exist_ok=True) return lib_path @cached_property def libdir(self) -> bytes: return bytestring_path(self.lib_path) # TODO automate teardown through hook registration def setup_beets(self): """Setup pristine global configuration and library for testing. Sets ``beets.config`` so we can safely use any functionality that uses the global configuration. All paths used are contained in a temporary directory Sets the following properties on itself. - ``temp_dir`` Path to a temporary directory containing all files specific to beets - ``libdir`` Path to a subfolder of ``temp_dir``, containing the library's media files. Same as ``config['directory']``. - ``lib`` Library instance created with the settings from ``config``. Make sure you call ``teardown_beets()`` afterwards. """ temp_dir_str = str(self.temp_dir_path) self.env_patcher = patch.dict( "os.environ", { "BEETSDIR": temp_dir_str, "HOME": temp_dir_str, # used by Confuse to create directories. }, ) self.env_patcher.start() self.config["directory"] = str(self.lib_path) if self.db_on_disk: dbpath = util.bytestring_path(self.config["library"].as_filename()) else: dbpath = ":memory:" self.lib = Library(dbpath, self.libdir) def teardown_beets(self): self.env_patcher.stop() self.lib._close() self.remove_temp_dir() # Library fixtures methods def create_item(self, **values): """Return an `Item` instance with sensible default values. The item receives its attributes from `**values` paratmeter. The `title`, `artist`, `album`, `track`, `format` and `path` attributes have defaults if they are not given as parameters. The `title` attribute is formatted with a running item count to prevent duplicates. The default for the `path` attribute respects the `format` value. The item is attached to the database from `self.lib`. """ values_ = { "title": "t\u00eftle {}", "artist": "the \u00e4rtist", "album": "the \u00e4lbum", "track": 1, "format": "MP3", } values_.update(values) values_["title"] = values_["title"].format(1) values_["db"] = self.lib item = Item(**values_) if "path" not in values: item["path"] = f"audio.{item['format'].lower()}" # mtime needs to be set last since other assignments reset it. item.mtime = 12345 return item def add_item(self, **values): """Add an item to the library and return it. Creates the item by passing the parameters to `create_item()`. If `path` is not set in `values` it is set to `item.destination()`. """ # When specifying a path, store it normalized (as beets does # ordinarily). if "path" in values: values["path"] = util.normpath(values["path"]) item = self.create_item(**values) item.add(self.lib) # Ensure every item has a path. if "path" not in values: item["path"] = item.destination() item.store() return item def add_item_fixture(self, **values): """Add an item with an actual audio file to the library.""" item = self.create_item(**values) extension = item["format"].lower() item["path"] = os.path.join( _common.RSRC, util.bytestring_path(f"min.{extension}") ) item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() return item def add_album(self, **values): item = self.add_item(**values) return self.lib.add_album([item]) def add_item_fixtures(self, ext="mp3", count=1): """Add a number of items with files to the database.""" # TODO base this on `add_item()` items = [] path = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}")) for i in range(count): item = Item.from_path(path) item.album = f"\u00e4lbum {i}" # Check unicode paths item.title = f"t\u00eftle {i}" # mtime needs to be set last since other assignments reset it. item.mtime = 12345 item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() items.append(item) return items def add_album_fixture( self, track_count=1, fname="full", ext="mp3", disc_count=1, ): """Add an album with files to the database.""" items = [] path = os.path.join( _common.RSRC, util.bytestring_path(f"{fname}.{ext}"), ) for discnumber in range(1, disc_count + 1): for i in range(track_count): item = Item.from_path(path) item.album = "\u00e4lbum" # Check unicode paths item.title = f"t\u00eftle {i}" item.disc = discnumber # mtime needs to be set last since other assignments reset it. item.mtime = 12345 item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() items.append(item) return self.lib.add_album(items) def create_mediafile_fixture(self, ext="mp3", images=[], target_dir=None): """Copy a fixture mediafile with the extension to `temp_dir`. `images` is a subset of 'png', 'jpg', and 'tiff'. For each specified extension a cover art image is added to the media file. """ if not target_dir: target_dir = self.temp_dir src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}")) handle, path = mkstemp(dir=target_dir) path = bytestring_path(path) os.close(handle) shutil.copyfile(syspath(src), syspath(path)) if images: mediafile = MediaFile(path) imgs = [] for img_ext in images: file = util.bytestring_path(f"image-2x3.{img_ext}") img_path = os.path.join(_common.RSRC, file) with open(img_path, "rb") as f: imgs.append(Image(f.read())) mediafile.images = imgs mediafile.save() return path # Safe file operations def create_temp_dir(self, **kwargs) -> str: return mkdtemp(**kwargs) def remove_temp_dir(self): """Delete the temporary directory created by `create_temp_dir`.""" shutil.rmtree(self.temp_dir_path) def touch(self, path, dir=None, content=""): """Create a file at `path` with given content. If `dir` is given, it is prepended to `path`. After that, if the path is relative, it is resolved with respect to `self.temp_dir`. """ if dir: path = os.path.join(dir, path) if not os.path.isabs(path): path = os.path.join(self.temp_dir, path) parent = os.path.dirname(path) if not os.path.isdir(syspath(parent)): os.makedirs(syspath(parent)) with open(syspath(path), "a+") as f: f.write(content) return path # A test harness for all beets tests. # Provides temporary, isolated configuration. class BeetsTestCase(unittest.TestCase, TestHelper): """A unittest.TestCase subclass that saves and restores beets' global configuration. This allows tests to make temporary modifications that will then be automatically removed when the test completes. Also provides some additional assertion methods, a temporary directory, and a DummyIO. """ def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() class ItemInDBTestCase(BeetsTestCase): """A test case that includes an in-memory library object (`lib`) and an item added to the library (`i`). """ def setUp(self): super().setUp() self.i = _common.item(self.lib) class PluginMixin(ConfigMixin): plugin: ClassVar[str] preload_plugin: ClassVar[bool] = True def setup_beets(self): super().setup_beets() if self.preload_plugin: self.load_plugins() def teardown_beets(self): super().teardown_beets() self.unload_plugins() def register_plugin( self, plugin_class: type[beets.plugins.BeetsPlugin] ) -> None: beets.plugins._instances.append(plugin_class()) def load_plugins(self, *plugins: str) -> None: """Load and initialize plugins by names. Similar setting a list of plugins in the configuration. Make sure you call ``unload_plugins()`` afterwards. """ # FIXME this should eventually be handled by a plugin manager plugins = (self.plugin,) if hasattr(self, "plugin") else plugins self.config["plugins"] = plugins beets.plugins.load_plugins() def unload_plugins(self) -> None: """Unload all plugins and remove them from the configuration.""" # FIXME this should eventually be handled by a plugin manager beets.plugins.BeetsPlugin.listeners.clear() beets.plugins.BeetsPlugin._raw_listeners.clear() self.config["plugins"] = [] beets.plugins._instances.clear() @contextmanager def configure_plugin(self, config: Any): self.config[self.plugin].set(config) self.load_plugins(self.plugin) yield self.unload_plugins() class PluginTestCase(PluginMixin, BeetsTestCase): pass class ImportHelper(TestHelper): """Provides tools to setup a library, a directory containing files that are to be imported and an import session. The class also provides stubs for the autotagging library and several assertions for the library. """ default_import_config: ClassVar[dict[str, bool]] = { "autotag": True, "copy": True, "hardlink": False, "link": False, "move": False, "resume": False, "singletons": False, "timid": True, } lib: Library importer: ImportSession @cached_property def import_path(self) -> Path: import_path = self.temp_dir_path / "import" import_path.mkdir(exist_ok=True) return import_path @cached_property def import_dir(self) -> bytes: return bytestring_path(self.import_path) def setUp(self): super().setUp() self.import_media = [] self.lib.path_formats = [ ("default", os.path.join("$artist", "$album", "$title")), ("singleton:true", os.path.join("singletons", "$title")), ("comp:true", os.path.join("compilations", "$album", "$title")), ] def prepare_track_for_import( self, track_id: int, album_path: Path, album_id: int | None = None, ) -> Path: track_path = album_path / f"track_{track_id}.mp3" shutil.copy(self.resource_path, track_path) medium = MediaFile(track_path) medium.update( { "album": f"Tag Album{f' {album_id}' if album_id else ''}", "albumartist": None, "mb_albumid": None, "comp": None, "artist": "Tag Artist", "title": f"Tag Track {track_id}", "track": track_id, "mb_trackid": None, } ) medium.save() self.import_media.append(medium) return track_path def prepare_album_for_import( self, item_count: int, album_id: int | None = None, album_path: Path | None = None, ) -> list[Path]: """Create an album directory with media files to import. The directory has following layout album/ track_1.mp3 track_2.mp3 track_3.mp3 """ if not album_path: album_dir = f"album_{album_id}" if album_id else "album" album_path = self.import_path / album_dir album_path.mkdir(exist_ok=True) return [ self.prepare_track_for_import(tid, album_path, album_id=album_id) for tid in range(1, item_count + 1) ] def prepare_albums_for_import(self, count: int = 1) -> None: album_dirs = self.import_path.glob("album_*") base_idx = int(str(max(album_dirs, default="0")).split("_")[-1]) + 1 for album_id in range(base_idx, count + base_idx): self.prepare_album_for_import(1, album_id=album_id) def _get_import_session(self, import_dir: bytes) -> ImportSession: return ImportSessionFixture( self.lib, loghandler=None, query=None, paths=[import_dir], ) def setup_importer( self, import_dir: bytes | None = None, **kwargs ) -> ImportSession: self.config["import"].set_args({**self.default_import_config, **kwargs}) self.importer = self._get_import_session(import_dir or self.import_dir) return self.importer def setup_singleton_importer(self, **kwargs) -> ImportSession: return self.setup_importer(singletons=True, **kwargs) class AsIsImporterMixin: def setUp(self): super().setUp() self.prepare_album_for_import(1) def run_asis_importer(self, **kwargs): importer = self.setup_importer(autotag=False, **kwargs) importer.run() return importer class ImportTestCase(ImportHelper, BeetsTestCase): pass class ImportSessionFixture(ImportSession): """ImportSession that can be controlled programaticaly. >>> lib = Library(':memory:') >>> importer = ImportSessionFixture(lib, paths=['/path/to/import']) >>> importer.add_choice(importer.Action.SKIP) >>> importer.add_choice(importer.Action.ASIS) >>> importer.default_choice = importer.Action.APPLY >>> importer.run() This imports ``/path/to/import`` into `lib`. It skips the first album and imports the second one with metadata from the tags. For the remaining albums, the metadata from the autotagger will be applied. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._choices = [] self._resolutions = [] default_choice = importer.Action.APPLY def add_choice(self, choice): self._choices.append(choice) def clear_choices(self): self._choices = [] def choose_match(self, task): try: choice = self._choices.pop(0) except IndexError: choice = self.default_choice if choice == importer.Action.APPLY: return task.candidates[0] elif isinstance(choice, int): return task.candidates[choice - 1] else: return choice choose_item = choose_match Resolution = Enum("Resolution", "REMOVE SKIP KEEPBOTH MERGE") default_resolution = "REMOVE" def resolve_duplicate(self, task, found_duplicates): try: res = self._resolutions.pop(0) except IndexError: res = self.default_resolution if res == self.Resolution.SKIP: task.set_choice(importer.Action.SKIP) elif res == self.Resolution.REMOVE: task.should_remove_duplicates = True elif res == self.Resolution.MERGE: task.should_merge_duplicates = True class TerminalImportSessionFixture(TerminalImportSession): def __init__(self, *args, **kwargs): self.io = kwargs.pop("io") super().__init__(*args, **kwargs) self._choices = [] default_choice = importer.Action.APPLY def add_choice(self, choice): self._choices.append(choice) def clear_choices(self): self._choices = [] def choose_match(self, task): self._add_choice_input() return super().choose_match(task) def choose_item(self, task): self._add_choice_input() return super().choose_item(task) def _add_choice_input(self): try: choice = self._choices.pop(0) except IndexError: choice = self.default_choice if choice == importer.Action.APPLY: self.io.addinput("A") elif choice == importer.Action.ASIS: self.io.addinput("U") elif choice == importer.Action.ALBUMS: self.io.addinput("G") elif choice == importer.Action.TRACKS: self.io.addinput("T") elif choice == importer.Action.SKIP: self.io.addinput("S") else: self.io.addinput("M") self.io.addinput(str(choice)) self._add_choice_input() class TerminalImportMixin(IOMixin, ImportHelper): """Provides_a terminal importer for the import session.""" def _get_import_session(self, import_dir: bytes) -> importer.ImportSession: return TerminalImportSessionFixture( self.lib, loghandler=None, query=None, io=self.io, paths=[import_dir], ) @dataclass class AutotagStub: """Stub out MusicBrainz album and track matcher and control what the autotagger returns. """ NONE = "NONE" IDENT = "IDENT" GOOD = "GOOD" BAD = "BAD" MISSING = "MISSING" matching: str length = 2 def install(self): self.patchers = [ patch("beets.metadata_plugins.album_for_id", lambda *_: None), patch("beets.metadata_plugins.track_for_id", lambda *_: None), patch("beets.metadata_plugins.candidates", self.candidates), patch( "beets.metadata_plugins.item_candidates", self.item_candidates ), ] for p in self.patchers: p.start() return self def restore(self): for p in self.patchers: p.stop() def candidates(self, items, artist, album, va_likely): if self.matching == self.IDENT: yield self._make_album_match(artist, album, len(items)) elif self.matching == self.GOOD: for i in range(self.length): yield self._make_album_match(artist, album, len(items), i) elif self.matching == self.BAD: for i in range(self.length): yield self._make_album_match(artist, album, len(items), i + 1) elif self.matching == self.MISSING: yield self._make_album_match(artist, album, len(items), missing=1) def item_candidates(self, item, artist, title): yield TrackInfo( title=title.replace("Tag", "Applied"), track_id="trackid", artist=artist.replace("Tag", "Applied"), artist_id="artistid", length=1, index=0, ) def _make_track_match(self, artist, album, number): return TrackInfo( title=f"Applied Track {number}", track_id=f"match {number}", artist=artist, length=1, index=0, ) def _make_album_match(self, artist, album, tracks, distance=0, missing=0): id = f" {'M' * distance}" if distance else "" if artist is None: artist = "Various Artists" else: artist = f"{artist.replace('Tag', 'Applied')}{id}" album = f"{album.replace('Tag', 'Applied')}{id}" track_infos = [] for i in range(tracks - missing): track_infos.append(self._make_track_match(artist, album, i + 1)) return AlbumInfo( artist=artist, album=album, tracks=track_infos, va=False, album_id=f"albumid{id}", artist_id=f"artistid{id}", albumtype="soundtrack", data_source="match_source", bandcamp_album_id="bc_url", ) class AutotagImportTestCase(ImportTestCase): matching = AutotagStub.IDENT def setUp(self): super().setUp() self.matcher = AutotagStub(self.matching).install() self.addCleanup(self.matcher.restore) class FetchImageHelper: """Helper mixin for mocking requests when fetching images with remote art sources. """ @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) IMAGEHEADER: ClassVar[dict[str, bytes]] = { "image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF", "image/png": b"\211PNG\r\n\032\n", "image/gif": b"GIF89a", # dummy type that is definitely not a valid image content type "image/watercolour": b"watercolour", "text/html": ( b"\n\n\n\n" b"\n\n" ), } def mock_response( self, url: str, content_type: str = "image/jpeg", file_type: None | str = None, ) -> None: # Potentially return a file of a type that differs from the # server-advertised content type to mimic misbehaving servers. if file_type is None: file_type = content_type try: # imghdr reads 32 bytes header = self.IMAGEHEADER[file_type].ljust(32, b"\x00") except KeyError: # If we can't return a file that looks like real file of the requested # type, better fail the test than returning something else, which might # violate assumption made when writing a test. raise AssertionError(f"Mocking {file_type} responses not supported") responses.add( responses.GET, url, content_type=content_type, body=header, ) class CleanupModulesMixin: modules: ClassVar[tuple[str, ...]] @classmethod def tearDownClass(cls) -> None: """Remove files created by the plugin.""" for module in cls.modules: clean_module_tempdir(module) ================================================ FILE: beets/ui/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module contains all of the core logic for beets' command-line interface. To invoke the CLI, just call beets.ui.main(). The actual CLI commands are implemented in the ui.commands module. """ from __future__ import annotations import errno import optparse import os.path import re import shutil import sqlite3 import sys import textwrap import traceback from functools import cache from typing import TYPE_CHECKING, Any import confuse from beets import config, library, logging, plugins, util from beets.dbcore import db from beets.dbcore import query as db_query from beets.util import as_string from beets.util.color import colorize from beets.util.deprecation import deprecate_for_maintainers from beets.util.diff import get_model_changes from beets.util.functemplate import template if TYPE_CHECKING: from collections.abc import Callable, Iterable # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": try: import colorama except ImportError: pass else: colorama.init() log = logging.getLogger("beets") if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. PF_KEY_QUERIES = { "comp": "comp:true", "singleton": "singleton:true", } class UserError(Exception): """UI exception. Commands should throw this in order to display nonrecoverable errors to the user. """ # Encoding utilities. def _in_encoding(): """Get the encoding to use for *inputting* strings from the console.""" return _stream_encoding(sys.stdin) def _out_encoding(): """Get the encoding to use for *outputting* strings to the console.""" return _stream_encoding(sys.stdout) def _stream_encoding(stream, default="utf-8"): """A helper for `_in_encoding` and `_out_encoding`: get the stream's preferred encoding, using a configured override or a default fallback if neither is not specified. """ # Configured override? encoding = config["terminal_encoding"].get() if encoding: return encoding # For testing: When sys.stdout or sys.stdin is a StringIO under the # test harness, it doesn't have an `encoding` attribute. Just use # UTF-8. if not hasattr(stream, "encoding"): return default # Python's guessed output stream encoding, or UTF-8 as a fallback # (e.g., when piped to a file). return stream.encoding or default def decargs(arglist): """Given a list of command-line argument bytestrings, attempts to decode them to Unicode strings when running under Python 2. .. deprecated:: 2.4.0 This function will be removed in 3.0.0. """ deprecate_for_maintainers("'beets.ui.decargs'") return arglist def print_(*strings: str, end: str = "\n") -> None: """Like print, but rather than raising an error when a character is not in the terminal's encoding's character set, just silently replaces it. The `end` keyword argument behaves similarly to the built-in `print` (it defaults to a newline). """ txt = f"{' '.join(strings or ('',))}{end}" # Encode the string and write it to stdout. # On Python 3, sys.stdout expects text strings and uses the # exception-throwing encoding error policy. To avoid throwing # errors and use our configurable encoding override, we use the # underlying bytes buffer instead. if hasattr(sys.stdout, "buffer"): out = txt.encode(_out_encoding(), "replace") sys.stdout.buffer.write(out) sys.stdout.buffer.flush() else: # In our test harnesses (e.g., DummyOut), sys.stdout.buffer # does not exist. We instead just record the text string. sys.stdout.write(txt) # Configuration wrappers. def _bool_fallback(a, b): """Given a boolean or None, return the original value or a fallback.""" if a is None: assert isinstance(b, bool) return b else: assert isinstance(a, bool) return a def should_write(write_opt=None): """Decide whether a command that updates metadata should also write tags, using the importer configuration as the default. """ return _bool_fallback(write_opt, config["import"]["write"].get(bool)) def should_move(move_opt=None): """Decide whether a command that updates metadata should also move files when they're inside the library, using the importer configuration as the default. Specifically, commands should move files after metadata updates only when the importer is configured *either* to move *or* to copy files. They should avoid moving files when the importer is configured not to touch any filenames. """ return _bool_fallback( move_opt, config["import"]["move"].get(bool) or config["import"]["copy"].get(bool), ) # Input prompts. def input_(prompt=None): """Like `input`, but decodes the result to a Unicode string. Raises a UserError if stdin is not available. The prompt is sent to stdout rather than stderr. A printed between the prompt and the input cursor. """ # raw_input incorrectly sends prompts to stderr, not stdout, so we # use print_() explicitly to display prompts. # https://bugs.python.org/issue1927 if prompt: print_(prompt, end=" ") try: resp = input() except EOFError: raise UserError("stdin stream ended while input required") return resp def input_options( options, require=False, prompt=None, fallback_prompt=None, numrange=None, default=None, max_width=72, ): """Prompts a user for input. The sequence of `options` defines the choices the user has. A single-letter shortcut is inferred for each option; the user's choice is returned as that single, lower-case letter. The options should be provided as lower-case strings unless a particular shortcut is desired; in that case, only that letter should be capitalized. By default, the first option is the default. `default` can be provided to override this. If `require` is provided, then there is no default. The prompt and fallback prompt are also inferred but can be overridden. If numrange is provided, it is a pair of `(high, low)` (both ints) indicating that, in addition to `options`, the user may enter an integer in that inclusive range. `max_width` specifies the maximum number of columns in the automatically generated prompt string. """ # Assign single letters to each option. Also capitalize the options # to indicate the letter. letters = {} display_letters = [] capitalized = [] first = True for option in options: # Is a letter already capitalized? for letter in option: if letter.isalpha() and letter.upper() == letter: found_letter = letter break else: # Infer a letter. for letter in option: if not letter.isalpha(): continue # Don't use punctuation. if letter not in letters: found_letter = letter break else: raise ValueError("no unambiguous lettering found") letters[found_letter.lower()] = option index = option.index(found_letter) # Mark the option's shortcut letter for display. if not require and ( (default is None and not numrange and first) or ( isinstance(default, str) and found_letter.lower() == default.lower() ) ): # The first option is the default; mark it. show_letter = f"[{found_letter.upper()}]" is_default = True else: show_letter = found_letter.upper() is_default = False # Colorize the letter shortcut. show_letter = colorize( "action_default" if is_default else "action", show_letter ) # Insert the highlighted letter back into the word. descr_color = "action_default" if is_default else "action_description" capitalized.append( colorize(descr_color, option[:index]) + show_letter + colorize(descr_color, option[index + 1 :]) ) display_letters.append(found_letter.upper()) first = False # The default is just the first option if unspecified. if require: default = None elif default is None: if numrange: default = numrange[0] else: default = display_letters[0].lower() # Make a prompt if one is not provided. if not prompt: prompt_parts = [] prompt_part_lengths = [] if numrange: if isinstance(default, int): default_name = str(default) default_name = colorize("action_default", default_name) tmpl = "# selection (default {})" prompt_parts.append(tmpl.format(default_name)) prompt_part_lengths.append(len(tmpl) - 2 + len(str(default))) else: prompt_parts.append("# selection") prompt_part_lengths.append(len(prompt_parts[-1])) prompt_parts += capitalized prompt_part_lengths += [len(s) for s in options] # Wrap the query text. # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow prompt = colorize("action", "\u279c ") line_length = 0 for i, (part, length) in enumerate( zip(prompt_parts, prompt_part_lengths) ): # Add punctuation. if i == len(prompt_parts) - 1: part += colorize("action_description", "?") else: part += colorize("action_description", ",") length += 1 # Choose either the current line or the beginning of the next. if line_length + length + 1 > max_width: prompt += "\n" line_length = 0 if line_length != 0: # Not the beginning of the line; need a space. part = f" {part}" length += 1 prompt += part line_length += length # Make a fallback prompt too. This is displayed if the user enters # something that is not recognized. if not fallback_prompt: fallback_prompt = "Enter one of " if numrange: fallback_prompt += "{}-{}, ".format(*numrange) fallback_prompt += f"{', '.join(display_letters)}:" resp = input_(prompt) while True: resp = resp.strip().lower() # Try default option. if default is not None and not resp: resp = default # Try an integer input if available. if numrange: try: resp = int(resp) except ValueError: pass else: low, high = numrange if low <= resp <= high: return resp else: resp = None # Try a normal letter input. if resp: resp = resp[0] if resp in letters: return resp # Prompt for new input. resp = input_(fallback_prompt) def input_yn(prompt, require=False): """Prompts the user for a "yes" or "no" response. The default is "yes" unless `require` is `True`, in which case there is no default. """ # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow yesno = colorize("action", "\u279c ") + colorize( "action_description", "Enter Y or N:" ) sel = input_options(("y", "n"), require, prompt, yesno) return sel == "y" def input_select_objects(prompt, objs, rep, prompt_all=None): """Prompt to user to choose all, none, or some of the given objects. Return the list of selected objects. `prompt` is the prompt string to use for each question (it should be phrased as an imperative verb). If `prompt_all` is given, it is used instead of `prompt` for the first (yes(/no/select) question. `rep` is a function to call on each object to print it out when confirming objects individually. """ choice = input_options( ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)" ) print() # Blank line. if choice == "y": # Yes. return objs elif choice == "s": # Select. out = [] for obj in objs: rep(obj) answer = input_options( ("y", "n", "q"), True, f"{prompt}? (yes/no/quit)", "Enter Y or N:", ) if answer == "y": out.append(obj) elif answer == "q": return out return out else: # No. return [] def get_path_formats(subview=None): """Get the configuration's path formats as a list of query/template pairs. """ path_formats = [] subview = subview or config["paths"] for query, view in subview.items(): query = PF_KEY_QUERIES.get(query, query) # Expand common queries. path_formats.append((query, template(view.as_str()))) return path_formats def get_replacements(): """Confuse validation function that reads regex/string pairs.""" replacements = [] for pattern, repl in config["replace"].get(dict).items(): repl = repl or "" try: replacements.append((re.compile(pattern), repl)) except re.error: raise UserError( f"malformed regular expression in replace: {pattern}" ) return replacements @cache def term_width() -> int: """Get the width (columns) of the terminal.""" columns, _ = shutil.get_terminal_size(fallback=(0, 0)) return columns if columns else config["ui"]["terminal_width"].get(int) def show_model_changes( new: library.LibModel, old: library.LibModel | None = None, fields: Iterable[str] | None = None, always: bool = False, print_obj: bool = True, ) -> bool: """Print a diff of changes between two library model states. Compares `new` against `old`, falling back to the database version of `new` when `old` is not provided. Optionally prints the original object label before listing field-level changes when `print_obj` is enabled. When `always` is set, the object label is printed even if no changes are detected. Returns whether any changes were found. """ old = old or new.get_fresh_from_db() changes = get_model_changes(new, old, fields) # Print changes. if print_obj and (changes or always): print_(format(old)) if changes: print_(textwrap.indent("\n".join(changes), " ")) return bool(changes) # Helper functions for option parsing. class CommonOptionsParser(optparse.OptionParser): """Offers a simple way to add common formatting options. Options available include: - matching albums instead of tracks: add_album_option() - showing paths instead of items/albums: add_path_option() - changing the format of displayed items/albums: add_format_option() The last one can have several behaviors: - against a special target - with a certain format - autodetected target with the album option Each method is fully documented in the related method. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._album_flags = False # this serves both as an indicator that we offer the feature AND allows # us to check whether it has been specified on the CLI - bypassing the # fact that arguments may be in any order def add_album_option(self, flags=("-a", "--album")): """Add a -a/--album option to match albums instead of tracks. If used then the format option can auto-detect whether we're setting the format for items or albums. Sets the album property on the options extracted from the CLI. """ album = optparse.Option( *flags, action="store_true", help="match albums instead of tracks" ) self.add_option(album) self._album_flags = set(flags) def _set_format( self, option, opt_str, value, parser, target=None, fmt=None, store_true=False, ): """Internal callback that sets the correct format while parsing CLI arguments. """ if store_true: setattr(parser.values, option.dest, True) # Use the explicitly specified format, or the string from the option. value = fmt or value or "" parser.values.format = value if target: config[target._format_config_key].set(value) else: if self._album_flags: if parser.values.album: target = library.Album else: # the option is either missing either not parsed yet if self._album_flags & set(parser.rargs): target = library.Album else: target = library.Item config[target._format_config_key].set(value) else: config[library.Item._format_config_key].set(value) config[library.Album._format_config_key].set(value) def add_path_option(self, flags=("-p", "--path")): """Add a -p/--path option to display the path instead of the default format. By default this affects both items and albums. If add_album_option() is used then the target will be autodetected. Sets the format property to '$path' on the options extracted from the CLI. """ path = optparse.Option( *flags, nargs=0, action="callback", callback=self._set_format, callback_kwargs={"fmt": "$path", "store_true": True}, help="print paths for matched items or albums", ) self.add_option(path) def add_format_option(self, flags=("-f", "--format"), target=None): """Add -f/--format option to print some LibModel instances with a custom format. `target` is optional and can be one of ``library.Item``, 'item', ``library.Album`` and 'album'. Several behaviors are available: - if `target` is given then the format is only applied to that LibModel - if the album option is used then the target will be autodetected - otherwise the format is applied to both items and albums. Sets the format property on the options extracted from the CLI. """ kwargs = {} if target: if isinstance(target, str): target = {"item": library.Item, "album": library.Album}[target] kwargs["target"] = target opt = optparse.Option( *flags, action="callback", callback=self._set_format, callback_kwargs=kwargs, help="print with custom format", ) self.add_option(opt) def add_all_common_options(self): """Add album, path and format options.""" self.add_album_option() self.add_path_option() self.add_format_option() # Subcommand parsing infrastructure. # # This is a fairly generic subcommand parser for optparse. It is # maintained externally here: # https://gist.github.com/462717 # There you will also find a better description of the code and a more # succinct example program. class Subcommand: """A subcommand of a root command-line application that may be invoked by a SubcommandOptionParser. """ func: Callable[[library.Library, optparse.Values, list[str]], Any] def __init__(self, name, parser=None, help="", aliases=(), hide=False): """Creates a new subcommand. name is the primary way to invoke the subcommand; aliases are alternate names. parser is an OptionParser responsible for parsing the subcommand's options. help is a short description of the command. If no parser is given, it defaults to a new, empty CommonOptionsParser. """ self.name = name self.parser = parser or CommonOptionsParser() self.aliases = aliases self.help = help self.hide = hide self._root_parser = None def print_help(self): self.parser.print_help() def parse_args(self, args): return self.parser.parse_args(args) @property def root_parser(self): return self._root_parser @root_parser.setter def root_parser(self, root_parser): self._root_parser = root_parser self.parser.prog = ( f"{as_string(root_parser.get_prog_name())} {self.name}" ) class SubcommandsOptionParser(CommonOptionsParser): """A variant of OptionParser that parses subcommands and their arguments. """ def __init__(self, *args, **kwargs): """Create a new subcommand-aware option parser. All of the options to OptionParser.__init__ are supported in addition to subcommands, a sequence of Subcommand objects. """ # A more helpful default usage. if "usage" not in kwargs: kwargs["usage"] = """ %prog COMMAND [ARGS...] %prog help COMMAND""" kwargs["add_help_option"] = False # Super constructor. super().__init__(*args, **kwargs) # Our root parser needs to stop on the first unrecognized argument. self.disable_interspersed_args() self.subcommands = [] def add_subcommand(self, *cmds): """Adds a Subcommand object to the parser's list of commands.""" for cmd in cmds: cmd.root_parser = self self.subcommands.append(cmd) # Add the list of subcommands to the help message. def format_help(self, formatter=None): # Get the original help message, to which we will append. out = super().format_help(formatter) if formatter is None: formatter = self.formatter # Subcommands header. result = ["\n"] result.append(formatter.format_heading("Commands")) formatter.indent() # Generate the display names (including aliases). # Also determine the help position. disp_names = [] help_position = 0 subcommands = [c for c in self.subcommands if not c.hide] subcommands.sort(key=lambda c: c.name) for subcommand in subcommands: name = subcommand.name if subcommand.aliases: name += f" ({', '.join(subcommand.aliases)})" disp_names.append(name) # Set the help position based on the max width. proposed_help_position = len(name) + formatter.current_indent + 2 if proposed_help_position <= formatter.max_help_position: help_position = max(help_position, proposed_help_position) # Add each subcommand to the output. for subcommand, name in zip(subcommands, disp_names): # Lifted directly from optparse.py. name_width = help_position - formatter.current_indent - 2 if len(name) > name_width: name = f"{' ' * formatter.current_indent}{name}\n" indent_first = help_position else: name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n" indent_first = 0 result.append(name) help_width = formatter.width - help_position help_lines = textwrap.wrap(subcommand.help, help_width) help_line = help_lines[0] if help_lines else "" result.append(f"{' ' * indent_first}{help_line}\n") result.extend( [f"{' ' * help_position}{line}\n" for line in help_lines[1:]] ) formatter.dedent() # Concatenate the original help message with the subcommand # list. return f"{out}{''.join(result)}" def _subcommand_for_name(self, name): """Return the subcommand in self.subcommands matching the given name. The name may either be the name of a subcommand or an alias. If no subcommand matches, returns None. """ for subcommand in self.subcommands: if name == subcommand.name or name in subcommand.aliases: return subcommand return None def parse_global_options(self, args): """Parse options up to the subcommand argument. Returns a tuple of the options object and the remaining arguments. """ options, subargs = self.parse_args(args) # Force the help command if options.help: subargs = ["help"] elif options.version: subargs = ["version"] return options, subargs def parse_subcommand(self, args): """Given the `args` left unused by a `parse_global_options`, return the invoked subcommand, the subcommand options, and the subcommand arguments. """ # Help is default command if not args: args = ["help"] cmdname = args.pop(0) subcommand = self._subcommand_for_name(cmdname) if not subcommand: raise UserError(f"unknown command '{cmdname}'") suboptions, subargs = subcommand.parse_args(args) return subcommand, suboptions, subargs optparse.Option.ALWAYS_TYPED_ACTIONS += ("callback",) # The main entry point and bootstrapping. def _setup( options: optparse.Values, lib: library.Library | None ) -> tuple[list[Subcommand], library.Library]: """Prepare and global state and updates it with command line options. Returns a list of subcommands, a list of plugins, and a library instance. """ config = _configure(options) plugins.load_plugins() # Get the default subcommands. from beets.ui.commands import default_commands subcommands = list(default_commands) subcommands.extend(plugins.commands()) if lib is None: lib = _open_library(config) plugins.send("library_opened", lib=lib) return subcommands, lib def _configure(options): """Amend the global configuration object with command line options.""" # Add any additional config files specified with --config. This # special handling lets specified plugins get loaded before we # finish parsing the command line. if getattr(options, "config", None) is not None: overlay_path = options.config del options.config config.set_file(overlay_path) else: overlay_path = None config.set_args(options) # Configure the logger. if config["verbose"].get(int): log.set_global_level(logging.DEBUG) else: log.set_global_level(logging.INFO) if overlay_path: log.debug( "overlaying configuration: {}", util.displayable_path(overlay_path) ) config_path = config.user_config_path() if os.path.isfile(config_path): log.debug("user configuration: {}", util.displayable_path(config_path)) else: log.debug( "no user configuration found at {}", util.displayable_path(config_path), ) log.debug("data directory: {}", util.displayable_path(config.config_dir())) return config def _ensure_db_directory_exists(path): if path == b":memory:": # in memory db return newpath = os.path.dirname(path) if not os.path.isdir(newpath): if input_yn( f"The database directory {util.displayable_path(newpath)} does not" " exist. Create it (Y/n)?" ): os.makedirs(newpath) def _open_library(config: confuse.LazyConfig) -> library.Library: """Create a new library instance from the configuration.""" dbpath = util.bytestring_path(config["library"].as_filename()) _ensure_db_directory_exists(dbpath) try: lib = library.Library( dbpath, config["directory"].as_filename(), get_path_formats(), get_replacements(), ) lib.get_item(0) # Test database connection. except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error: log.debug("{}", traceback.format_exc()) raise UserError( f"database file {util.displayable_path(dbpath)} cannot not be" f" opened: {db_error}" ) log.debug( "library database: {}\nlibrary directory: {}", util.displayable_path(lib.path), util.displayable_path(lib.directory), ) return lib def _raw_main(args: list[str], lib=None) -> None: """A helper function for `main` without top-level exception handling. """ parser = SubcommandsOptionParser() parser.add_format_option(flags=("--format-item",), target=library.Item) parser.add_format_option(flags=("--format-album",), target=library.Album) parser.add_option( "-l", "--library", dest="library", help="library database file to use" ) parser.add_option( "-d", "--directory", dest="directory", help="destination music directory", ) parser.add_option( "-v", "--verbose", dest="verbose", action="count", help="log more details (use twice for even more)", ) parser.add_option( "-c", "--config", dest="config", help="path to configuration file" ) def parse_csl_callback( option: optparse.Option, _, value: str, parser: SubcommandsOptionParser ): """Parse a comma-separated list of values.""" setattr( parser.values, option.dest, # type: ignore[arg-type] list(filter(None, value.split(","))), ) parser.add_option( "-p", "--plugins", dest="plugins", action="callback", callback=parse_csl_callback, help="a comma-separated list of plugins to load", ) parser.add_option( "-P", "--disable-plugins", dest="disabled_plugins", action="callback", callback=parse_csl_callback, help="a comma-separated list of plugins to disable", ) parser.add_option( "-h", "--help", dest="help", action="store_true", help="show this help message and exit", ) parser.add_option( "--version", dest="version", action="store_true", help=optparse.SUPPRESS_HELP, ) options, subargs = parser.parse_global_options(args) # Special case for the `config --edit` command: bypass _setup so # that an invalid configuration does not prevent the editor from # starting. if ( subargs and subargs[0] == "config" and ("-e" in subargs or "--edit" in subargs) ): from beets.ui.commands.config import config_edit return config_edit(options) test_lib = bool(lib) subcommands, lib = _setup(options, lib) parser.add_subcommand(*subcommands) subcommand, suboptions, subargs = parser.parse_subcommand(subargs) subcommand.func(lib, suboptions, subargs) plugins.send("cli_exit", lib=lib) if not test_lib: # Clean up the library unless it came from the test harness. lib._close() def main(args=None): """Run the main command-line interface for beets. Includes top-level exception handlers that print friendly error messages. """ if "AppData\\Local\\Microsoft\\WindowsApps" in sys.exec_prefix: log.error( "error: beets is unable to use the Microsoft Store version of " "Python. Please install Python from https://python.org.\n" "error: More details can be found here " "https://beets.readthedocs.io/en/stable/guides/main.html" ) sys.exit(1) try: _raw_main(args) except UserError as exc: message = exc.args[0] if exc.args else None log.error("error: {}", message) sys.exit(1) except util.HumanReadableError as exc: exc.log(log) sys.exit(1) except library.FileOperationError as exc: # These errors have reasonable human-readable descriptions, but # we still want to log their tracebacks for debugging. log.debug("{}", traceback.format_exc()) log.error("{}", exc) sys.exit(1) except confuse.ConfigError as exc: log.error("configuration error: {}", exc) sys.exit(1) except db_query.InvalidQueryError as exc: log.error("invalid query: {}", exc) sys.exit(1) except OSError as exc: if exc.errno == errno.EPIPE: # "Broken pipe". End silently. sys.stderr.close() else: raise except KeyboardInterrupt: # Silently ignore ^C except in verbose mode. log.debug("{}", traceback.format_exc()) except db.DBAccessError as exc: log.error( "database access error: {}\n" "the library file might have a permissions problem", exc, ) sys.exit(1) ================================================ FILE: beets/ui/commands/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module provides the default commands for beets' command-line interface. """ from beets.util.deprecation import deprecate_imports from .completion import completion_cmd from .config import config_cmd from .fields import fields_cmd from .help import HelpCommand from .import_ import import_cmd from .list import list_cmd from .modify import modify_cmd from .move import move_cmd from .remove import remove_cmd from .stats import stats_cmd from .update import update_cmd from .version import version_cmd from .write import write_cmd def __getattr__(name: str): """Handle deprecated imports.""" return deprecate_imports( __name__, { "TerminalImportSession": "beets.ui.commands.import_.session", "PromptChoice": "beets.util", }, name, ) # The list of default subcommands. This is populated with Subcommand # objects that can be fed to a SubcommandsOptionParser. default_commands = [ fields_cmd, HelpCommand(), import_cmd, list_cmd, update_cmd, remove_cmd, stats_cmd, version_cmd, modify_cmd, move_cmd, write_cmd, config_cmd, completion_cmd, ] __all__ = ["default_commands"] ================================================ FILE: beets/ui/commands/completion.py ================================================ """The 'completion' command: print shell script for command line completion.""" import os import re from beets import library, logging, plugins, ui from beets.util import syspath # Global logger. log = logging.getLogger("beets") def print_completion(*args): from beets.ui.commands import default_commands for line in completion_script(default_commands + plugins.commands()): ui.print_(line, end="") if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS): log.warning( "Warning: Unable to find the bash-completion package. " "Command line completion might not work." ) completion_cmd = ui.Subcommand( "completion", help="print shell script that provides command line completion", ) completion_cmd.func = print_completion completion_cmd.hide = True BASH_COMPLETION_PATHS = [ b"/etc/bash_completion", b"/usr/share/bash-completion/bash_completion", b"/usr/local/share/bash-completion/bash_completion", # SmartOS b"/opt/local/share/bash-completion/bash_completion", # Homebrew (before bash-completion2) b"/usr/local/etc/bash_completion", ] def completion_script(commands): """Yield the full completion shell script as strings. ``commands`` is alist of ``ui.Subcommand`` instances to generate completion data for. """ base_script = os.path.join( os.path.dirname(__file__), "./completion_base.sh" ) with open(base_script) as base_script: yield base_script.read() options = {} aliases = {} command_names = [] # Collect subcommands for cmd in commands: name = cmd.name command_names.append(name) for alias in cmd.aliases: if re.match(r"^\w+$", alias): aliases[alias] = name options[name] = {"flags": [], "opts": []} for opts in cmd.parser._get_all_options()[1:]: if opts.action in ("store_true", "store_false"): option_type = "flags" else: option_type = "opts" options[name][option_type].extend( opts._short_opts + opts._long_opts ) # Add global options options["_global"] = { "flags": ["-v", "--verbose"], "opts": "-l --library -c --config -d --directory -h --help".split(" "), } # Add flags common to all commands options["_common"] = {"flags": ["-h", "--help"]} # Start generating the script yield "_beet() {\n" # Command names yield f" local commands={' '.join(command_names)!r}\n" yield "\n" # Command aliases yield f" local aliases={' '.join(aliases.keys())!r}\n" for alias, cmd in aliases.items(): yield f" local alias__{alias.replace('-', '_')}={cmd}\n" yield "\n" # Fields fields = library.Item._fields.keys() | library.Album._fields.keys() yield f" fields={' '.join(fields)!r}\n" # Command options for cmd, opts in options.items(): for option_type, option_list in opts.items(): if option_list: option_list = " ".join(option_list) yield ( " local" f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n" ) yield " _beet_dispatch\n" yield "}\n" ================================================ FILE: beets/ui/commands/completion_base.sh ================================================ # This file is part of beets. # Copyright (c) 2014, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # Completion for the `beet` command # ================================= # # Load this script to complete beets subcommands, options, and # queries. # # If a beets command is found on the command line it completes filenames and # the subcommand's options. Otherwise it will complete global options and # subcommands. If the previous option on the command line expects an argument, # it also completes filenames or directories. Options are only # completed if '-' has already been typed on the command line. # # Note that completion of plugin commands only works for those plugins # that were enabled when running `beet completion`. It does not check # plugins dynamically # # Currently, only Bash 3.2 and newer is supported and the # `bash-completion` package (v2.8 or newer) is required. # # TODO # ---- # # * There are some issues with arguments that are quoted on the command line. # # * Complete arguments for the `--format` option by expanding field variables. # # beet ls -f "$tit[TAB] # beet ls -f "$title # # * Support long options with `=`, e.g. `--config=file`. Debian's bash # completion package can handle this. # # Note that 'bash-completion' v2.8 is a part of Debian 10, which is part of # LTS until 2024-06-30. After this date, the minimum version requirement can # be changed, and newer features can be used unconditionally. See PR#5301. # if [[ ${BASH_COMPLETION_VERSINFO[0]} -ne 2 \ || ${BASH_COMPLETION_VERSINFO[1]} -lt 8 ]]; then echo "Incompatible version of 'bash-completion'!" return 1 fi # The later code relies on 'bash-completion' version 2.12, but older versions # are still supported. Here, we provide implementations of the newer functions # in terms of older ones, if 'bash-completion' is too old to have them. if [[ ${BASH_COMPLETION_VERSINFO[1]} -lt 12 ]]; then _comp_get_words() { _get_comp_words_by_ref "$@" } _comp_compgen_filedir() { _filedir "$@" } fi # Determines the beets subcommand and dispatches the completion # accordingly. _beet_dispatch() { local cur prev cmd= COMPREPLY=() _comp_get_words -n : cur prev # Look for the beets subcommand local arg for (( i=1; i < COMP_CWORD; i++ )); do arg="${COMP_WORDS[i]}" if _list_include_item "${opts___global}" $arg; then ((i++)) elif [[ "$arg" != -* ]]; then cmd="$arg" break fi done # Replace command shortcuts if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then eval "cmd=\$alias__${cmd//-/_}" fi case $cmd in help) COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) ;; list|remove|move|update|write|stats) _beet_complete_query ;; "") _beet_complete_global ;; *) _beet_complete ;; esac } # Adds option and file completion to COMPREPLY for the subcommand $cmd _beet_complete() { if [[ $cur == -* ]]; then local opts flags completions eval "opts=\$opts__${cmd//-/_}" eval "flags=\$flags__${cmd//-/_}" completions="${flags___common} ${opts} ${flags}" COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) else _comp_compgen_filedir fi } # Add global options and subcommands to the completion _beet_complete_global() { case $prev in -h|--help) # Complete commands COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) return ;; -l|--library|-c|--config) # Filename completion _comp_compgen_filedir return ;; -d|--directory) # Directory completion _comp_compgen_filedir -d return ;; esac if [[ $cur == -* ]]; then local completions="$opts___global $flags___global" COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then local cmd eval "cmd=\$alias__${cur//-/_}" COMPREPLY+=( "$cmd" ) else COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) fi } _beet_complete_query() { local opts eval "opts=\$opts__${cmd//-/_}" if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then _beet_complete elif [[ $cur != \'* && $cur != \"* && $cur != *:* ]]; then # Do not complete quoted queries or those who already have a field # set. compopt -o nospace COMPREPLY+=( $(compgen -S : -W "$fields" -- $cur) ) return 0 fi } # Returns true if the space separated list $1 includes $2 _list_include_item() { [[ " $1 " == *[[:space:]]$2[[:space:]]* ]] } # This is where beets dynamically adds the _beet function. This # function sets the variables $flags, $opts, $commands, and $aliases. complete -o filenames -F _beet beet ================================================ FILE: beets/ui/commands/config.py ================================================ """The 'config' command: show and edit user configuration.""" import os from beets import config, ui from beets.util import displayable_path, editor_command, interactive_open def config_func(lib, opts, args): # Make sure lazy configuration is loaded config.resolve() # Print paths. if opts.paths: filenames = [] for source in config.sources: if not opts.defaults and source.default: continue if source.filename: filenames.append(source.filename) # In case the user config file does not exist, prepend it to the # list. user_path = config.user_config_path() if user_path not in filenames: filenames.insert(0, user_path) for filename in filenames: ui.print_(displayable_path(filename)) # Open in editor. elif opts.edit: # Note: This branch *should* be unreachable # since the normal flow should be short-circuited # by the special case in ui._raw_main config_edit(opts) # Dump configuration. else: config_out = config.dump(full=opts.defaults, redact=opts.redact) if config_out.strip() != "{}": ui.print_(config_out) else: print("Empty configuration") def config_edit(cli_options): """Open a program to edit the user configuration. An empty config file is created if no existing config file exists. """ path = cli_options.config or config.user_config_path() editor = editor_command() if not editor: raise ui.UserError( "Please set the VISUAL or EDITOR environment variable to edit" " configuration." ) try: if not os.path.isfile(path): open(path, "w+").close() interactive_open([path], editor) except FileNotFoundError: raise ui.UserError(f"Editor {editor!r} not found.") except OSError as exc: raise ui.UserError(f"Could not edit configuration: {exc}") config_cmd = ui.Subcommand("config", help="show or edit the user configuration") config_cmd.parser.add_option( "-p", "--paths", action="store_true", help="show files that configuration was loaded from", ) config_cmd.parser.add_option( "-e", "--edit", action="store_true", help="edit user configuration with $VISUAL (or $EDITOR)", ) config_cmd.parser.add_option( "-d", "--defaults", action="store_true", help="include the default configuration", ) config_cmd.parser.add_option( "-c", "--clear", action="store_false", dest="redact", default=True, help="do not redact sensitive fields", ) config_cmd.func = config_func ================================================ FILE: beets/ui/commands/fields.py ================================================ """The `fields` command: show available fields for queries and format strings.""" import textwrap from beets import library, ui def _print_keys(query): """Given a SQLite query result, print the `key` field of each returned row, with indentation of 2 spaces. """ for row in query: ui.print_(f" {row['key']}") def fields_func(lib, opts, args): def _print_rows(names): names.sort() ui.print_(textwrap.indent("\n".join(names), " ")) ui.print_("Item fields:") _print_rows(library.Item.all_keys()) ui.print_("Album fields:") _print_rows(library.Album.all_keys()) with lib.transaction() as tx: # The SQL uses the DISTINCT to get unique values from the query unique_fields = "SELECT DISTINCT key FROM ({})" ui.print_("Item flexible attributes:") _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) ui.print_("Album flexible attributes:") _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) fields_cmd = ui.Subcommand( "fields", help="show fields available for queries and format strings" ) fields_cmd.func = fields_func ================================================ FILE: beets/ui/commands/help.py ================================================ """The 'help' command: show help information for commands.""" from beets import ui class HelpCommand(ui.Subcommand): def __init__(self): super().__init__( "help", aliases=("?",), help="give detailed help on a specific sub-command", ) def func(self, lib, opts, args): if args: cmdname = args[0] helpcommand = self.root_parser._subcommand_for_name(cmdname) if not helpcommand: raise ui.UserError(f"unknown command '{cmdname}'") helpcommand.print_help() else: self.root_parser.print_help() ================================================ FILE: beets/ui/commands/import_/__init__.py ================================================ """The `import` command: import new music into the library.""" import os from beets import config, logging, plugins, ui from beets.util import displayable_path, normpath, syspath from .session import TerminalImportSession # Global logger. log = logging.getLogger("beets") def paths_from_logfile(path): """Parse the logfile and yield skipped paths to pass to the `import` command. """ with open(path, encoding="utf-8") as fp: for i, line in enumerate(fp, start=1): verb, sep, paths = line.rstrip("\n").partition(" ") if not sep: raise ValueError(f"line {i} is invalid") # Ignore informational lines that don't need to be re-imported. if verb in {"import", "duplicate-keep", "duplicate-replace"}: continue if verb not in {"asis", "skip", "duplicate-skip"}: raise ValueError(f"line {i} contains unknown verb {verb}") yield os.path.commonpath(paths.split("; ")) def parse_logfiles(logfiles): """Parse all `logfiles` and yield paths from it.""" for logfile in logfiles: try: yield from paths_from_logfile(syspath(normpath(logfile))) except ValueError as err: raise ui.UserError( f"malformed logfile {displayable_path(logfile)}: {err}" ) from err except OSError as err: raise ui.UserError( f"unreadable logfile {displayable_path(logfile)}: {err}" ) from err def import_files(lib, paths: list[bytes], query): """Import the files in the given list of paths or matching the query. """ # Check parameter consistency. if config["import"]["quiet"] and config["import"]["timid"]: raise ui.UserError("can't be both quiet and timid") # Open the log. if config["import"]["log"].get() is not None: logpath = syspath(config["import"]["log"].as_filename()) try: loghandler = logging.FileHandler(logpath, encoding="utf-8") except OSError: raise ui.UserError( "Could not open log file for writing:" f" {displayable_path(logpath)}" ) else: loghandler = None # Never ask for input in quiet mode. if config["import"]["resume"].get() == "ask" and config["import"]["quiet"]: config["import"]["resume"] = False session = TerminalImportSession(lib, loghandler, paths, query) session.run() # Emit event. plugins.send("import", lib=lib, paths=paths) def import_func(lib, opts, args: list[str]): config["import"].set_args(opts) # Special case: --copy flag suppresses import_move (which would # otherwise take precedence). if opts.copy: config["import"]["move"] = False if opts.library: query = args byte_paths = [] else: query = None paths = args # The paths from the logfiles go into a separate list to allow handling # errors differently from user-specified paths. paths_from_logfiles = list(parse_logfiles(opts.from_logfiles or [])) if not paths and not paths_from_logfiles: raise ui.UserError("no path specified") byte_paths = [os.fsencode(p) for p in paths] paths_from_logfiles = [os.fsencode(p) for p in paths_from_logfiles] # Check the user-specified directories. for path in byte_paths: if not os.path.exists(syspath(normpath(path))): raise ui.UserError( f"no such file or directory: {displayable_path(path)}" ) # Check the directories from the logfiles, but don't throw an error in # case those paths don't exist. Maybe some of those paths have already # been imported and moved separately, so logging a warning should # suffice. for path in paths_from_logfiles: if not os.path.exists(syspath(normpath(path))): log.warning( "No such file or directory: {}", displayable_path(path) ) continue byte_paths.append(path) # If all paths were read from a logfile, and none of them exist, throw # an error if not byte_paths: raise ui.UserError("none of the paths are importable") import_files(lib, byte_paths, query) def _store_dict(option, opt_str, value, parser): """Custom action callback to parse options which have ``key=value`` pairs as values. All such pairs passed for this option are aggregated into a dictionary. """ dest = option.dest option_values = getattr(parser.values, dest, None) if option_values is None: # This is the first supplied ``key=value`` pair of option. # Initialize empty dictionary and get a reference to it. setattr(parser.values, dest, {}) option_values = getattr(parser.values, dest) try: key, value = value.split("=", 1) if not (key and value): raise ValueError except ValueError: raise ui.UserError( f"supplied argument `{value}' is not of the form `key=value'" ) option_values[key] = value import_cmd = ui.Subcommand( "import", help="import new music", aliases=("imp", "im") ) import_cmd.parser.add_option( "-c", "--copy", action="store_true", default=None, help="copy tracks into library directory (default)", ) import_cmd.parser.add_option( "-C", "--nocopy", action="store_false", dest="copy", help="don't copy tracks (opposite of -c)", ) import_cmd.parser.add_option( "-m", "--move", action="store_true", dest="move", help="move tracks into the library (overrides -c)", ) import_cmd.parser.add_option( "-w", "--write", action="store_true", default=None, help="write new metadata to files' tags (default)", ) import_cmd.parser.add_option( "-W", "--nowrite", action="store_false", dest="write", help="don't write metadata (opposite of -w)", ) import_cmd.parser.add_option( "-a", "--autotag", action="store_true", dest="autotag", help="infer tags for imported files (default)", ) import_cmd.parser.add_option( "-A", "--noautotag", action="store_false", dest="autotag", help="don't infer tags for imported files (opposite of -a)", ) import_cmd.parser.add_option( "-p", "--resume", action="store_true", default=None, help="resume importing if interrupted", ) import_cmd.parser.add_option( "-P", "--noresume", action="store_false", dest="resume", help="do not try to resume importing", ) import_cmd.parser.add_option( "-q", "--quiet", action="store_true", dest="quiet", help="never prompt for input: skip albums instead", ) import_cmd.parser.add_option( "--quiet-fallback", type="string", dest="quiet_fallback", help="decision in quiet mode when no strong match: skip or asis", ) import_cmd.parser.add_option( "-l", "--log", dest="log", help="file to log untaggable albums for later review", ) import_cmd.parser.add_option( "-s", "--singletons", action="store_true", help="import individual tracks instead of full albums", ) import_cmd.parser.add_option( "-t", "--timid", dest="timid", action="store_true", help="always confirm all actions", ) import_cmd.parser.add_option( "-L", "--library", dest="library", action="store_true", help="retag items matching a query", ) import_cmd.parser.add_option( "-i", "--incremental", dest="incremental", action="store_true", help="skip already-imported directories", ) import_cmd.parser.add_option( "-I", "--noincremental", dest="incremental", action="store_false", help="do not skip already-imported directories", ) import_cmd.parser.add_option( "-R", "--incremental-skip-later", action="store_true", dest="incremental_skip_later", help="do not record skipped files during incremental import", ) import_cmd.parser.add_option( "-r", "--noincremental-skip-later", action="store_false", dest="incremental_skip_later", help="record skipped files during incremental import", ) import_cmd.parser.add_option( "--from-scratch", dest="from_scratch", action="store_true", help="erase existing metadata before applying new metadata", ) import_cmd.parser.add_option( "--flat", dest="flat", action="store_true", help="import an entire tree as a single album", ) import_cmd.parser.add_option( "-g", "--group-albums", dest="group_albums", action="store_true", help="group tracks in a folder into separate albums", ) import_cmd.parser.add_option( "--pretend", dest="pretend", action="store_true", help="just print the files to import", ) import_cmd.parser.add_option( "-S", "--search-id", dest="search_ids", action="append", metavar="ID", help="restrict matching to a specific metadata backend ID", ) import_cmd.parser.add_option( "--from-logfile", dest="from_logfiles", action="append", metavar="PATH", help="read skipped paths from an existing logfile", ) import_cmd.parser.add_option( "--set", dest="set_fields", action="callback", callback=_store_dict, metavar="FIELD=VALUE", help="set the given fields to the supplied values", ) import_cmd.func = import_func ================================================ FILE: beets/ui/commands/import_/display.py ================================================ from __future__ import annotations import os from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING from beets import config, ui from beets.autotag import hooks from beets.util import displayable_path from beets.util.color import colorize, dist_colorize from beets.util.diff import colordiff from beets.util.layout import Side, get_layout_lines, indent from beets.util.units import human_seconds_short if TYPE_CHECKING: from collections.abc import Sequence import confuse from beets import autotag from beets.autotag.distance import Distance from beets.library.models import Item from beets.util.color import ColorName VARIOUS_ARTISTS = "Various Artists" @dataclass class ChangeRepresentation: """Keeps track of all information needed to generate a (colored) text representation of the changes that will be made if an album or singleton's tags are changed according to `match`, which must be an AlbumMatch or TrackMatch object, accordingly. """ cur_artist: str cur_name: str match: autotag.hooks.Match @cached_property def changed_prefix(self) -> str: return colorize("changed", "\u2260") @cached_property def _indentation_config(self) -> confuse.Subview: return config["ui"]["import"]["indentation"] @cached_property def indent_header(self) -> str: return indent(self._indentation_config["match_header"].get(int)) @cached_property def indent_detail(self) -> str: return indent(self._indentation_config["match_details"].get(int)) @cached_property def indent_tracklist(self) -> str: return indent(self._indentation_config["match_tracklist"].get(int)) def print_layout(self, indent: str, left: Side, right: Side) -> None: for line in get_layout_lines(indent, left, right, ui.term_width()): ui.print_(line) def show_match_header(self) -> None: """Print out a 'header' identifying the suggested match (album name, artist name,...) and summarizing the changes that would be made should the user accept the match. """ # Print newline at beginning of change block. ui.print_("") # 'Match' line and similarity. ui.print_( f"{self.indent_header}Match ({dist_string(self.match.distance)}):" ) artist_name_str = f"{self.match.info.artist} - {self.match.info.name}" ui.print_( self.indent_header + dist_colorize(artist_name_str, self.match.distance) ) # Penalties. penalties = penalty_string(self.match.distance) if penalties: ui.print_(f"{self.indent_header}{penalties}") # Disambiguation. disambig = disambig_string(self.match.info) if disambig: ui.print_(f"{self.indent_header}{disambig}") # Data URL. if self.match.info.data_url: url = colorize("text_faint", f"{self.match.info.data_url}") ui.print_(f"{self.indent_header}{url}") def show_match_details(self) -> None: """Print out the details of the match, including changes in album name and artist name. """ # Artist. artist_l, artist_r = self.cur_artist or "", self.match.info.artist or "" if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = "", "" if artist_l != artist_r: artist_l, artist_r = colordiff(artist_l, artist_r) left = Side(f"{self.changed_prefix} Artist: ", artist_l, "") right = Side("", artist_r, "") self.print_layout(self.indent_detail, left, right) else: ui.print_(f"{self.indent_detail}*", "Artist:", artist_r) if self.cur_name: type_ = self.match.type name_l, name_r = self.cur_name or "", self.match.info.name if self.cur_name != self.match.info.name != VARIOUS_ARTISTS: name_l, name_r = colordiff(name_l, name_r) left = Side(f"{self.changed_prefix} {type_}: ", name_l, "") right = Side("", name_r, "") self.print_layout(self.indent_detail, left, right) else: ui.print_(f"{self.indent_detail}*", f"{type_}:", name_r) def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str: """Construct a line with the current medium's info.""" track_media = track_info.get("media", "Media") # Build output string. if self.match.info.mediums > 1 and track_info.disctitle: return ( f"* {track_media} {track_info.medium}: {track_info.disctitle}" ) elif self.match.info.mediums > 1: return f"* {track_media} {track_info.medium}" elif track_info.disctitle: return f"* {track_media}: {track_info.disctitle}" else: return "" def format_index(self, track_info: hooks.TrackInfo | Item) -> str: """Return a string representing the track index of the given TrackInfo or Item object. """ if isinstance(track_info, hooks.TrackInfo): index = track_info.index medium_index = track_info.medium_index medium = track_info.medium mediums = self.match.info.mediums else: index = medium_index = track_info.track medium = track_info.disc mediums = track_info.disctotal if config["per_disc_numbering"]: if mediums and mediums > 1: return f"{medium}-{medium_index}" else: return str(medium_index if medium_index is not None else index) else: return str(index) def make_track_numbers( self, item: Item, track_info: hooks.TrackInfo ) -> tuple[str, str, bool]: """Format colored track indices.""" cur_track = self.format_index(item) new_track = self.format_index(track_info) changed = False # Choose color based on change. highlight_color: ColorName if cur_track != new_track: changed = True if item.track in (track_info.index, track_info.medium_index): highlight_color = "text_highlight_minor" else: highlight_color = "text_highlight" else: highlight_color = "text_faint" lhs_track = colorize(highlight_color, f"(#{cur_track})") rhs_track = colorize(highlight_color, f"(#{new_track})") return lhs_track, rhs_track, changed @staticmethod def make_track_titles( item: Item, track_info: hooks.TrackInfo ) -> tuple[str, str, bool]: """Format colored track titles.""" new_title = track_info.name if not item.title.strip(): # If there's no title, we use the filename. Don't colordiff. cur_title = displayable_path(os.path.basename(item.path)) return cur_title, new_title, True else: # If there is a title, highlight differences. cur_title = item.title.strip() cur_col, new_col = colordiff(cur_title, new_title) return cur_col, new_col, cur_title != new_title @staticmethod def make_track_lengths( item: Item, track_info: hooks.TrackInfo ) -> tuple[str, str, bool]: """Format colored track lengths.""" changed = False highlight_color: ColorName if ( item.length and track_info.length and abs(item.length - track_info.length) >= config["ui"]["length_diff_thresh"].as_number() ): highlight_color = "text_highlight" changed = True else: highlight_color = "text_highlight_minor" # Handle nonetype lengths by setting to 0 cur_length0 = item.length if item.length else 0 new_length0 = track_info.length if track_info.length else 0 # format into string cur_length = f"({human_seconds_short(cur_length0)})" new_length = f"({human_seconds_short(new_length0)})" # colorize lhs_length = colorize(highlight_color, cur_length) rhs_length = colorize(highlight_color, new_length) return lhs_length, rhs_length, changed def make_line( self, item: Item, track_info: hooks.TrackInfo ) -> tuple[Side, Side]: """Extract changes from item -> new TrackInfo object, and colorize appropriately. Returns (lhs, rhs) for column printing. """ # Track titles. lhs_title, rhs_title, diff_title = self.make_track_titles( item, track_info ) # Track number change. lhs_track, rhs_track, diff_track = self.make_track_numbers( item, track_info ) # Length change. lhs_length, rhs_length, diff_length = self.make_track_lengths( item, track_info ) changed = diff_title or diff_track or diff_length # Construct lhs and rhs dicts. # Previously, we printed the penalties, however this is no longer # the case, thus the 'info' dictionary is unneeded. # penalties = penalty_string(self.match.distance.tracks[track_info]) lhs = Side( f"{self.changed_prefix if changed else '*'} {lhs_track} ", lhs_title, f" {lhs_length}", ) if not changed: # Only return the left side, as nothing changed. return (lhs, Side("", "", "")) return (lhs, Side(f"{rhs_track} ", rhs_title, f" {rhs_length}")) def print_tracklist(self, lines: list[tuple[Side, Side]]) -> None: """Calculates column widths for tracks stored as line tuples: (left, right). Then prints each line of tracklist. """ if len(lines) == 0: # If no lines provided, e.g. details not required, do nothing. return # Check how to fit content into terminal window indent_width = len(self.indent_tracklist) terminal_width = ui.term_width() joiner_width = len("* -> ") col_width = (terminal_width - indent_width - joiner_width) // 2 max_width_l = max(left.rendered_width for left, _ in lines) max_width_r = max(right.rendered_width for _, right in lines) if ((max_width_l <= col_width) and (max_width_r <= col_width)) or ( ((max_width_l > col_width) or (max_width_r > col_width)) and ((max_width_l + max_width_r) <= col_width * 2) ): # All content fits. Either both maximum widths are below column # widths, or one of the columns is larger than allowed but the # other is smaller than allowed. # In this case we can afford to shrink the columns to fit their # largest string col_width_l = max_width_l col_width_r = max_width_r else: # Not all content fits - stick with original half/half split col_width_l = col_width col_width_r = col_width # Print out each line, using the calculated width from above. for left, right in lines: left = left._replace(width=col_width_l) right = right._replace(width=col_width_r) self.print_layout(self.indent_tracklist, left, right) class AlbumChange(ChangeRepresentation): match: autotag.hooks.AlbumMatch def show_match_tracks(self) -> None: """Print out the tracks of the match, summarizing changes the match suggests for them. """ pairs = sorted( self.match.item_info_pairs, key=lambda pair: pair[1].index or 0 ) # Build up LHS and RHS for track difference display. The `lines` list # contains `(left, right)` tuples. lines: list[tuple[Side, Side]] = [] medium = disctitle = None for item, track_info in pairs: # If the track is the first on a new medium, show medium # number and title. if medium != track_info.medium or disctitle != track_info.disctitle: # Create header for new medium header = self.make_medium_info_line(track_info) if header != "": # Print tracks from previous medium self.print_tracklist(lines) lines = [] ui.print_(f"{self.indent_detail}{header}") # Save new medium details for future comparison. medium, disctitle = track_info.medium, track_info.disctitle # Construct the line tuple for the track. left, right = self.make_line(item, track_info) if right.contents != "": lines.append((left, right)) else: if config["import"]["detail"]: lines.append((left, right)) self.print_tracklist(lines) # Missing and unmatched tracks. if self.match.extra_tracks: ui.print_( "Missing tracks" f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -" f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):" ) for track_info in self.match.extra_tracks: line = f" ! {track_info.title} (#{self.format_index(track_info)})" if track_info.length: line += f" ({human_seconds_short(track_info.length)})" ui.print_(colorize("text_warning", line)) if self.match.extra_items: ui.print_(f"Unmatched tracks ({len(self.match.extra_items)}):") for item in self.match.extra_items: line = f" ! {item.title} (#{self.format_index(item)})" if item.length: line += f" ({human_seconds_short(item.length)})" ui.print_(colorize("text_warning", line)) class TrackChange(ChangeRepresentation): """Track change representation, comparing item with match.""" match: autotag.hooks.TrackMatch def show_change( cur_artist: str, cur_album: str, match: hooks.AlbumMatch ) -> None: """Print out a representation of the changes that will be made if an album's tags are changed according to `match`, which must be an AlbumMatch object. """ change = AlbumChange(cur_artist, cur_album, match) # Print the match header. change.show_match_header() # Print the match details. change.show_match_details() # Print the match tracks. change.show_match_tracks() def show_item_change(item: Item, match: hooks.TrackMatch) -> None: """Print out the change that would occur by tagging `item` with the metadata from `match`, a TrackMatch object. """ change = TrackChange(item.artist, item.title, match) # Print the match header. change.show_match_header() # Print the match details. change.show_match_details() def disambig_string(info: hooks.Info) -> str: """Generate a string for an AlbumInfo or TrackInfo object that provides context that helps disambiguate similar-looking albums and tracks. """ if isinstance(info, hooks.AlbumInfo): disambig = get_album_disambig_fields(info) elif isinstance(info, hooks.TrackInfo): disambig = get_singleton_disambig_fields(info) else: return "" return ", ".join(disambig) def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]: out = [] chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq() calculated_values = { "index": f"Index {info.index}", "track_alt": f"Track {info.track_alt}", "album": ( f"[{info.album}]" if ( config["import"]["singleton_album_disambig"].get() and info.get("album") ) else "" ), } for field in chosen_fields: if field in calculated_values: out.append(str(calculated_values[field])) else: try: out.append(str(info[field])) except (AttributeError, KeyError): print(f"Disambiguation string key {field} does not exist.") return out def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: out = [] chosen_fields = config["match"]["album_disambig_fields"].as_str_seq() calculated_values = { "media": ( f"{info.mediums}x{info.media}" if (info.mediums and info.mediums > 1) else info.media ), } for field in chosen_fields: if field in calculated_values: out.append(str(calculated_values[field])) else: try: out.append(str(info[field])) except (AttributeError, KeyError): print(f"Disambiguation string key {field} does not exist.") return out def dist_string(dist: Distance) -> str: """Formats a distance (a float) as a colorized similarity percentage string. """ string = f"{(1 - dist) * 100:.1f}%" return dist_colorize(string, dist) def penalty_string(distance: Distance, limit: int | None = None) -> str: """Returns a colorized string that indicates all the penalties applied to a distance object. """ penalties = [] for key in distance.keys(): key = key.replace("album_", "") key = key.replace("track_", "") key = key.replace("_", " ") penalties.append(key) if penalties: if limit and len(penalties) > limit: penalties = [*penalties[:limit], "..."] # Prefix penalty string with U+2260: Not Equal To penalty_string = f"\u2260 {', '.join(penalties)}" return colorize("changed", penalty_string) return "" ================================================ FILE: beets/ui/commands/import_/session.py ================================================ from collections import Counter from itertools import chain from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation from beets.util import PromptChoice, displayable_path from beets.util.color import colorize, dist_colorize from beets.util.units import human_bytes, human_seconds_short from .display import ( disambig_string, penalty_string, show_change, show_item_change, ) # Global logger. log = logging.getLogger("beets") class TerminalImportSession(importer.ImportSession): """An import session that runs in a terminal.""" def choose_match(self, task): """Given an initial autotagging of items, go through an interactive dance with the user to ask for a choice of metadata. Returns an AlbumMatch object, ASIS, or SKIP. """ # Show what we're tagging. ui.print_() path_str0 = displayable_path(task.paths, "\n") path_str = colorize("import_path", path_str0) items_str0 = f"({len(task.items)} items)" items_str = colorize("import_path_items", items_str0) ui.print_(" ".join([path_str, items_str])) # Let plugins display info or prompt the user before we go through the # process of selecting candidate. results = plugins.send( "import_task_before_choice", session=self, task=task ) actions = [action for action in results if action] if len(actions) == 1: return actions[0] elif len(actions) > 1: raise plugins.PluginConflictError( "Only one handler for `import_task_before_choice` may return " "an action." ) # Take immediate action if appropriate. action = _summary_judgment(task.rec) if action == importer.Action.APPLY: match = task.candidates[0] show_change(task.cur_artist, task.cur_album, match) return match elif action is not None: return action # Loop until we have a choice. while True: # Ask for a choice from the user. The result of # `choose_candidate` may be an `importer.Action`, an # `AlbumMatch` object for a specific selection, or a # `PromptChoice`. choices = self._get_choices(task) choice = choose_candidate( task.candidates, False, task.rec, task.cur_artist, task.cur_album, itemcount=len(task.items), choices=choices, ) # Basic choices that require no more action here. if choice in (importer.Action.SKIP, importer.Action.ASIS): # Pass selection to main control flow. return choice # Plugin-provided choices. We invoke the associated callback # function. elif choice in choices: post_choice = choice.callback(self, task) if isinstance(post_choice, importer.Action): return post_choice elif isinstance(post_choice, autotag.Proposal): # Use the new candidates and continue around the loop. task.candidates = post_choice.candidates task.rec = post_choice.recommendation # Otherwise, we have a specific match selection. else: # We have a candidate! Finish tagging. Here, choice is an # AlbumMatch object. assert isinstance(choice, autotag.AlbumMatch) return choice def choose_item(self, task): """Ask the user for a choice about tagging a single item. Returns either an action constant or a TrackMatch object. """ ui.print_() ui.print_(displayable_path(task.item.path)) candidates, rec = task.candidates, task.rec # Take immediate action if appropriate. action = _summary_judgment(task.rec) if action == importer.Action.APPLY: match = candidates[0] show_item_change(task.item, match) return match elif action is not None: return action while True: # Ask for a choice. choices = self._get_choices(task) choice = choose_candidate( candidates, True, rec, item=task.item, choices=choices ) if choice in (importer.Action.SKIP, importer.Action.ASIS): return choice elif choice in choices: post_choice = choice.callback(self, task) if isinstance(post_choice, importer.Action): return post_choice elif isinstance(post_choice, autotag.Proposal): candidates = post_choice.candidates rec = post_choice.recommendation else: # Chose a candidate. assert isinstance(choice, autotag.TrackMatch) return choice def resolve_duplicate(self, task, found_duplicates): """Decide what to do when a new album or item seems similar to one that's already in the library. """ log.warning( "This {} is already in the library!", ("album" if task.is_album else "item"), ) if config["import"]["quiet"]: # In quiet mode, don't prompt -- just skip. log.info("Skipping.") sel = "s" else: # Print some detail about the existing and new items so the # user can make an informed decision. for duplicate in found_duplicates: ui.print_( "Old: " + summarize_items( ( list(duplicate.items()) if task.is_album else [duplicate] ), not task.is_album, ) ) if config["import"]["duplicate_verbose_prompt"]: if task.is_album: for dup in duplicate.items(): print(f" {dup}") else: print(f" {duplicate}") ui.print_( "New: " + summarize_items( task.imported_items(), not task.is_album, ) ) if config["import"]["duplicate_verbose_prompt"]: for item in task.imported_items(): print(f" {item}") sel = ui.input_options( ("Skip new", "Keep all", "Remove old", "Merge all") ) if sel == "s": # Skip new. task.set_choice(importer.Action.SKIP) elif sel == "k": # Keep both. Do nothing; leave the choice intact. pass elif sel == "r": # Remove old. task.should_remove_duplicates = True elif sel == "m": task.should_merge_duplicates = True else: assert False def should_resume(self, path): return ui.input_yn( f"Import of the directory:\n{displayable_path(path)}\n" "was interrupted. Resume (Y/n)?" ) def _get_choices(self, task): """Get the list of prompt choices that should be presented to the user. This consists of both built-in choices and ones provided by plugins. The `before_choose_candidate` event is sent to the plugins, with session and task as its parameters. Plugins are responsible for checking the right conditions and returning a list of `PromptChoice`s, which is flattened and checked for conflicts. If two or more choices have the same short letter, a warning is emitted and all but one choices are discarded, giving preference to the default importer choices. Returns a list of `PromptChoice`s. """ # Standard, built-in choices. choices = [ PromptChoice("s", "Skip", lambda s, t: importer.Action.SKIP), PromptChoice("u", "Use as-is", lambda s, t: importer.Action.ASIS), ] if task.is_album: choices += [ PromptChoice( "t", "as Tracks", lambda s, t: importer.Action.TRACKS ), PromptChoice( "g", "Group albums", lambda s, t: importer.Action.ALBUMS ), ] choices += [ PromptChoice("e", "Enter search", manual_search), PromptChoice("i", "enter Id", manual_id), PromptChoice("b", "aBort", abort_action), ] # Send the before_choose_candidate event and flatten list. extra_choices = list( chain( *plugins.send( "before_choose_candidate", session=self, task=task ) ) ) # Add a "dummy" choice for the other baked-in option, for # duplicate checking. all_choices = [ PromptChoice("a", "Apply", None), *choices, *extra_choices, ] # Check for conflicts. short_letters = [c.short for c in all_choices] if len(short_letters) != len(set(short_letters)): # Duplicate short letter has been found. duplicates = [ i for i, count in Counter(short_letters).items() if count > 1 ] for short in duplicates: # Keep the first of the choices, removing the rest. dup_choices = [c for c in all_choices if c.short == short] for c in dup_choices[1:]: log.warning( "Prompt choice '{0.long}' removed due to conflict " "with '{1[0].long}' (short letter: '{0.short}')", c, dup_choices, ) extra_choices.remove(c) return choices + extra_choices def summarize_items(items, singleton): """Produces a brief summary line describing a set of items. Used for manually resolving duplicates during import. `items` is a list of `Item` objects. `singleton` indicates whether this is an album or single-item import (if the latter, them `items` should only have one element). """ summary_parts = [] if not singleton: summary_parts.append(f"{len(items)} items") format_counts = {} for item in items: format_counts[item.format] = format_counts.get(item.format, 0) + 1 if len(format_counts) == 1: # A single format. summary_parts.append(items[0].format) else: # Enumerate all the formats by decreasing frequencies: for fmt, count in sorted( format_counts.items(), key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]), ): summary_parts.append(f"{fmt} {count}") if items: average_bitrate = sum([item.bitrate for item in items]) / len(items) total_duration = sum([item.length for item in items]) total_filesize = sum([item.filesize for item in items]) summary_parts.append(f"{int(average_bitrate / 1000)}kbps") if items[0].format == "FLAC": sample_bits = ( f"{round(int(items[0].samplerate) / 1000, 1)}kHz" f"/{items[0].bitdepth} bit" ) summary_parts.append(sample_bits) summary_parts.append(human_seconds_short(total_duration)) summary_parts.append(human_bytes(total_filesize)) return ", ".join(summary_parts) def _summary_judgment(rec: Recommendation) -> importer.Action | None: """Determines whether a decision should be made without even asking the user. This occurs in quiet mode and when an action is chosen for NONE recommendations. Return None if the user should be queried. Otherwise, returns an action. May also print to the console if a summary judgment is made. """ action: importer.Action | None if config["import"]["quiet"]: if rec == Recommendation.strong: return importer.Action.APPLY else: action = config["import"]["quiet_fallback"].as_choice( { "skip": importer.Action.SKIP, "asis": importer.Action.ASIS, } ) elif config["import"]["timid"]: return None elif rec == Recommendation.none: action = config["import"]["none_rec_action"].as_choice( { "skip": importer.Action.SKIP, "asis": importer.Action.ASIS, "ask": None, } ) else: return None if action == importer.Action.SKIP: ui.print_("Skipping.") elif action == importer.Action.ASIS: ui.print_("Importing as-is.") return action def choose_candidate( candidates, singleton, rec, cur_artist=None, cur_album=None, item=None, itemcount=None, choices=[], ): """Given a sorted list of candidates, ask the user for a selection of which candidate to use. Applies to both full albums and singletons (tracks). Candidates are either AlbumMatch or TrackMatch objects depending on `singleton`. for albums, `cur_artist`, `cur_album`, and `itemcount` must be provided. For singletons, `item` must be provided. `choices` is a list of `PromptChoice`s to be used in each prompt. Returns one of the following: * the result of the choice, which may be SKIP or ASIS * a candidate (an AlbumMatch/TrackMatch object) * a chosen `PromptChoice` from `choices` """ # Sanity check. if singleton: assert item is not None else: assert cur_artist is not None assert cur_album is not None # Build helper variables for the prompt choices. choice_opts = tuple(c.long for c in choices) choice_actions = {c.short: c for c in choices} # Zero candidates. if not candidates: if singleton: ui.print_("No matching recordings found.") else: ui.print_(f"No matching release found for {itemcount} tracks.") ui.print_( "For help, see: " "https://beets.readthedocs.org/en/latest/faq.html#nomatch" ) sel = ui.input_options(choice_opts) if sel in choice_actions: return choice_actions[sel] else: assert False # Is the change good enough? bypass_candidates = False if rec != Recommendation.none: match = candidates[0] bypass_candidates = True while True: # Display and choose from candidates. require = rec <= Recommendation.low if not bypass_candidates: # Display list of candidates. ui.print_("") ui.print_( f"Finding tags for {'track' if singleton else 'album'} " f'"{item.artist if singleton else cur_artist} -' f' {item.title if singleton else cur_album}".' ) ui.print_(" Candidates:") for i, match in enumerate(candidates): # Index, metadata, and distance. index0 = f"{i + 1}." index = dist_colorize(index0, match.distance) dist = f"({(1 - match.distance) * 100:.1f}%)" distance = dist_colorize(dist, match.distance) metadata = f"{match.info.artist} - {match.info.name}" if i == 0: metadata = dist_colorize(metadata, match.distance) else: metadata = colorize("text_highlight_minor", metadata) line1 = [index, distance, metadata] ui.print_(f" {' '.join(line1)}") # Penalties. penalties = penalty_string(match.distance, 3) if penalties: ui.print_(f"{' ' * 13}{penalties}") # Disambiguation disambig = disambig_string(match.info) if disambig: ui.print_(f"{' ' * 13}{disambig}") # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) if sel == "m": pass elif sel in choice_actions: return choice_actions[sel] else: # Numerical selection. match = candidates[sel - 1] if sel != 1: # When choosing anything but the first match, # disable the default action. require = True bypass_candidates = False # Show what we're about to do. if singleton: show_item_change(item, match) else: show_change(cur_artist, cur_album, match) # Exact match => tag automatically if we're not in timid mode. if rec == Recommendation.strong and not config["import"]["timid"]: return match # Ask for confirmation. default = config["import"]["default_action"].as_choice( { "apply": "a", "skip": "s", "asis": "u", "none": None, } ) if default is None: require = True # Bell ring when user interaction is needed. if config["import"]["bell"]: ui.print_("\a", end="") sel = ui.input_options( ("Apply", "More candidates", *choice_opts), require=require, default=default, ) if sel == "a": return match elif sel in choice_actions: return choice_actions[sel] def manual_search(session, task): """Get a new `Proposal` using manual search criteria. Input either an artist and album (for full albums) or artist and track name (for singletons) for manual search. """ artist = ui.input_("Artist:").strip() name = ui.input_("Album:" if task.is_album else "Track:").strip() if task.is_album: _, _, prop = autotag.tag_album(task.items, artist, name) return prop else: return autotag.tag_item(task.item, artist, name) def manual_id(session, task): """Get a new `Proposal` using a manually-entered ID. Input an ID, either for an album ("release") or a track ("recording"). """ prompt = f"Enter {'release' if task.is_album else 'recording'} ID:" search_id = ui.input_(prompt).strip() if task.is_album: _, _, prop = autotag.tag_album(task.items, search_ids=search_id.split()) return prop else: return autotag.tag_item(task.item, search_ids=search_id.split()) def abort_action(session, task): """A prompt choice callback that aborts the importer.""" raise importer.ImportAbortError() ================================================ FILE: beets/ui/commands/list.py ================================================ """The 'list' command: query and show library contents.""" from beets import ui def list_items(lib, query, album, fmt=""): """Print out items in lib matching query. If album, then search for albums instead of single items. """ if album: for album in lib.albums(query): ui.print_(format(album, fmt)) else: for item in lib.items(query): ui.print_(format(item, fmt)) def list_func(lib, opts, args): list_items(lib, args, opts.album) list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",)) list_cmd.parser.usage += "\nExample: %prog -f '$album: $title' artist:beatles" list_cmd.parser.add_all_common_options() list_cmd.func = list_func ================================================ FILE: beets/ui/commands/modify.py ================================================ """The `modify` command: change metadata fields.""" from beets import library, ui from beets.util import functemplate from .utils import do_query def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): """Modifies matching items according to user-specified assignments and deletions. `mods` is a dictionary of field and value pairse indicating assignments. `dels` is a list of fields to be deleted. """ # Parse key=value specifications into a dictionary. model_cls = library.Album if album else library.Item # Get the items to modify. items, albums = do_query(lib, query, album, False) objs = albums if album else items # Apply changes *temporarily*, preview them, and collect modified # objects. ui.print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.") changed = [] templates = { key: functemplate.template(value) for key, value in mods.items() } for obj in objs: obj_mods = { key: model_cls._parse(key, obj.evaluate_template(templates[key])) for key in mods.keys() } if print_and_modify(obj, obj_mods, dels) and obj not in changed: changed.append(obj) # Still something to do? if not changed: ui.print_("No changes to make.") return # Confirm action. if confirm: if write and move: extra = ", move and write tags" elif write: extra = " and write tags" elif move: extra = " and move" else: extra = "" changed = ui.input_select_objects( f"Really modify{extra}", changed, lambda o: print_and_modify(o, mods, dels), ) # Apply changes to database and files with lib.transaction(): for obj in changed: obj.try_sync(write, move, inherit) def print_and_modify(obj, mods, dels): """Print the modifications to an item and return a bool indicating whether any changes were made. `mods` is a dictionary of fields and values to update on the object; `dels` is a sequence of fields to delete. """ obj.update(mods) for field in dels: try: del obj[field] except KeyError: pass return ui.show_model_changes(obj) def modify_parse_args(args): """Split the arguments for the modify subcommand into query parts, assignments (field=value), and deletions (field!). Returns the result as a three-tuple in that order. """ mods = {} dels = [] query = [] for arg in args: if arg.endswith("!") and "=" not in arg and ":" not in arg: dels.append(arg[:-1]) # Strip trailing !. elif "=" in arg and ":" not in arg.split("=", 1)[0]: key, val = arg.split("=", 1) mods[key] = val else: query.append(arg) return query, mods, dels def modify_func(lib, opts, args): query, mods, dels = modify_parse_args(args) if not mods and not dels: raise ui.UserError("no modifications specified") modify_items( lib, mods, dels, query, ui.should_write(opts.write), ui.should_move(opts.move), opts.album, not opts.yes, opts.inherit, ) modify_cmd = ui.Subcommand( "modify", help="change metadata fields", aliases=("mod",) ) modify_cmd.parser.add_option( "-m", "--move", action="store_true", dest="move", help="move files in the library directory", ) modify_cmd.parser.add_option( "-M", "--nomove", action="store_false", dest="move", help="don't move files in library", ) modify_cmd.parser.add_option( "-w", "--write", action="store_true", default=None, help="write new metadata to files' tags (default)", ) modify_cmd.parser.add_option( "-W", "--nowrite", action="store_false", dest="write", help="don't write metadata (opposite of -w)", ) modify_cmd.parser.add_album_option() modify_cmd.parser.add_format_option(target="item") modify_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) modify_cmd.parser.add_option( "-I", "--noinherit", action="store_false", dest="inherit", default=True, help="when modifying albums, don't also change item data", ) modify_cmd.func = modify_func ================================================ FILE: beets/ui/commands/move.py ================================================ """The 'move' command: Move/copy files to the library or a new base directory.""" from __future__ import annotations import os from typing import TYPE_CHECKING from beets import logging, ui from beets.util import MoveOperation, displayable_path, normpath, syspath from beets.util.diff import colordiff from .utils import do_query if TYPE_CHECKING: from beets.util import PathLike # Global logger. log = logging.getLogger("beets") def show_path_changes(path_changes): """Given a list of tuples (source, destination) that indicate the path changes, log the changes as INFO-level output to the beets log. The output is guaranteed to be unicode. Every pair is shown on a single line if the terminal width permits it, else it is split over two lines. E.g., Source -> Destination vs. Source -> Destination """ sources, destinations = zip(*path_changes) # Ensure unicode output sources = list(map(displayable_path, sources)) destinations = list(map(displayable_path, destinations)) # Calculate widths for terminal split col_width = (ui.term_width() - len(" -> ")) // 2 max_width = len(max(sources + destinations, key=len)) if max_width > col_width: # Print every change over two lines for source, dest in zip(sources, destinations): color_source, color_dest = colordiff(source, dest) ui.print_(f"{color_source} \n -> {color_dest}") else: # Print every change on a single line, and add a header title_pad = max_width - len("Source ") + len(" -> ") ui.print_(f"Source {' ' * title_pad} Destination") for source, dest in zip(sources, destinations): pad = max_width - len(source) color_source, color_dest = colordiff(source, dest) ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") def move_items( lib, dest_path: PathLike, query, copy, album, pretend, confirm=False, export=False, ): """Moves or copies items to a new base directory, given by dest. If dest is None, then the library's base directory is used, making the command "consolidate" files. """ dest = os.fsencode(dest_path) if dest_path else dest_path items, albums = do_query(lib, query, album, False) objs = albums if album else items num_objs = len(objs) # Filter out files that don't need to be moved. def isitemmoved(item): return item.path != item.destination(basedir=dest) def isalbummoved(album): return any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] num_unmoved = num_objs - len(objs) # Report unmoved files that match the query. unmoved_msg = "" if num_unmoved > 0: unmoved_msg = f" ({num_unmoved} already in place)" copy = copy or export # Exporting always copies. action = "Copying" if copy else "Moving" act = "copy" if copy else "move" entity = "album" if album else "item" log.info( "{} {} {}{}{}.", action, len(objs), entity, "s" if len(objs) != 1 else "", unmoved_msg, ) if not objs: return if pretend: if album: show_path_changes( [ (item.path, item.destination(basedir=dest)) for obj in objs for item in obj.items() ] ) else: show_path_changes( [(obj.path, obj.destination(basedir=dest)) for obj in objs] ) else: if confirm: objs = ui.input_select_objects( f"Really {act}", objs, lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] ), ) for obj in objs: log.debug("moving: {.filepath}", obj) if export: # Copy without affecting the database. obj.move( operation=MoveOperation.COPY, basedir=dest, store=False ) else: # Ordinary move/copy: store the new path. if copy: obj.move(operation=MoveOperation.COPY, basedir=dest) else: obj.move(operation=MoveOperation.MOVE, basedir=dest) def move_func(lib, opts, args): dest = opts.dest if dest is not None: dest = normpath(dest) if not os.path.isdir(syspath(dest)): raise ui.UserError(f"no such directory: {displayable_path(dest)}") move_items( lib, dest, args, opts.copy, opts.album, opts.pretend, opts.timid, opts.export, ) move_cmd = ui.Subcommand("move", help="move or copy items", aliases=("mv",)) move_cmd.parser.add_option( "-d", "--dest", metavar="DIR", dest="dest", help="destination directory" ) move_cmd.parser.add_option( "-c", "--copy", default=False, action="store_true", help="copy instead of moving", ) move_cmd.parser.add_option( "-p", "--pretend", default=False, action="store_true", help="show how files would be moved, but don't touch anything", ) move_cmd.parser.add_option( "-t", "--timid", dest="timid", action="store_true", help="always confirm all actions", ) move_cmd.parser.add_option( "-e", "--export", default=False, action="store_true", help="copy without changing the database path", ) move_cmd.parser.add_album_option() move_cmd.func = move_func ================================================ FILE: beets/ui/commands/remove.py ================================================ """The `remove` command: remove items from the library (and optionally delete files).""" from beets import ui from .utils import do_query def remove_items(lib, query, album, delete, force): """Remove items matching query from lib. If album, then match and remove whole albums. If delete, also remove files from disk. """ # Get the matching items. items, albums = do_query(lib, query, album) objs = albums if album else items # Confirm file removal if not forcing removal. if not force: # Prepare confirmation with user. album_str = ( f" in {len(albums)} album{'s' if len(albums) > 1 else ''}" if album else "" ) if delete: fmt = "$path - $title" prompt = "Really DELETE" prompt_all = ( "Really DELETE" f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}" ) else: fmt = "" prompt = "Really remove from the library?" prompt_all = ( "Really remove" f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}" " from the library?" ) # Helpers for printing affected items def fmt_track(t): ui.print_(format(t, fmt)) def fmt_album(a): ui.print_() for i in a.items(): fmt_track(i) fmt_obj = fmt_album if album else fmt_track # Show all the items. for o in objs: fmt_obj(o) # Confirm with user. objs = ui.input_select_objects( prompt, objs, fmt_obj, prompt_all=prompt_all ) if not objs: return # Remove (and possibly delete) items. with lib.transaction(): for obj in objs: obj.remove(delete) def remove_func(lib, opts, args): remove_items(lib, args, opts.album, opts.delete, opts.force) remove_cmd = ui.Subcommand( "remove", help="remove matching items from the library", aliases=("rm",) ) remove_cmd.parser.add_option( "-d", "--delete", action="store_true", help="also remove files from disk" ) remove_cmd.parser.add_option( "-f", "--force", action="store_true", help="do not ask when removing items" ) remove_cmd.parser.add_album_option() remove_cmd.func = remove_func ================================================ FILE: beets/ui/commands/stats.py ================================================ """The 'stats' command: show library statistics.""" import os from beets import logging, ui from beets.util import syspath from beets.util.units import human_bytes, human_seconds # Global logger. log = logging.getLogger("beets") def show_stats(lib, query, exact): """Shows some statistics about the matched items.""" items = lib.items(query) total_size = 0 total_time = 0.0 total_items = 0 artists = set() albums = set() album_artists = set() for item in items: if exact: try: total_size += os.path.getsize(syspath(item.path)) except OSError as exc: log.info("could not get size of {.path}: {}", item, exc) else: total_size += int(item.length * item.bitrate / 8) total_time += item.length total_items += 1 artists.add(item.artist) album_artists.add(item.albumartist) if item.album_id: albums.add(item.album_id) size_str = human_bytes(total_size) if exact: size_str += f" ({total_size} bytes)" ui.print_(f"""Tracks: {total_items} Total time: {human_seconds(total_time)} {f" ({total_time:.2f} seconds)" if exact else ""} {"Total size" if exact else "Approximate total size"}: {size_str} Artists: {len(artists)} Albums: {len(albums)} Album artists: {len(album_artists)}""") def stats_func(lib, opts, args): show_stats(lib, args, opts.exact) stats_cmd = ui.Subcommand( "stats", help="show statistics about the library or a query" ) stats_cmd.parser.add_option( "-e", "--exact", action="store_true", help="exact size and time" ) stats_cmd.func = stats_func ================================================ FILE: beets/ui/commands/update.py ================================================ """The `update` command: Update library contents according to on-disk tags.""" import os from beets import library, logging, ui from beets.util import ancestry, syspath from beets.util.color import colorize from .utils import do_query # Global logger. log = logging.getLogger("beets") def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): """For all the items matched by the query, update the library to reflect the item's embedded tags. :param fields: The fields to be stored. If not specified, all fields will be. :param exclude_fields: The fields to not be stored. If not specified, all fields will be. """ with lib.transaction(): items, _ = do_query(lib, query, album) if move and fields is not None and "path" not in fields: # Special case: if an item needs to be moved, the path field has to # updated; otherwise the new path will not be reflected in the # database. fields.append("path") if fields is None: # no fields were provided, update all media fields item_fields = fields or library.Item._media_fields if move and "path" not in item_fields: # move is enabled, add 'path' to the list of fields to update item_fields.add("path") else: # fields was provided, just update those item_fields = fields # get all the album fields to update album_fields = fields or library.Album._fields.keys() if exclude_fields: # remove any excluded fields from the item and album sets item_fields = [f for f in item_fields if f not in exclude_fields] album_fields = [f for f in album_fields if f not in exclude_fields] # Walk through the items and pick up their changes. affected_albums = set() for item in items: # Item deleted? if not item.path or not os.path.exists(syspath(item.path)): ui.print_(format(item)) ui.print_(colorize("text_error", " deleted")) if not pretend: item.remove(True) affected_albums.add(item.album_id) continue # Did the item change since last checked? if item.current_mtime() <= item.mtime: log.debug( "skipping {0.filepath} because mtime is up to date ({0.mtime})", item, ) continue # Read new data. try: item.read() except library.ReadError as exc: log.error("error reading {.filepath}: {}", item, exc) continue # Special-case album artist when it matches track artist. (Hacky # but necessary for preserving album-level metadata for non- # autotagged imports.) if not item.albumartist: old_item = lib.get_item(item.id) if old_item.albumartist == old_item.artist == item.artist: item.albumartist = old_item.albumartist item._dirty.discard("albumartist") # Check for and display changes. changed = ui.show_model_changes(item, fields=item_fields) # Save changes. if not pretend: if changed: # Move the item if it's in the library. if move and lib.directory in ancestry(item.path): item.move(store=False) item.store(fields=item_fields) affected_albums.add(item.album_id) else: # The file's mtime was different, but there were no # changes to the metadata. Store the new mtime, # which is set in the call to read(), so we don't # check this again in the future. item.store(fields=item_fields) # Skip album changes while pretending. if pretend: return # Modify affected albums to reflect changes in their items. for album_id in affected_albums: if album_id is None: # Singletons. continue album = lib.get_album(album_id) if not album: # Empty albums have already been removed. log.debug("emptied album {}", album_id) continue first_item = album.items().get() # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = first_item[key] album.store(fields=album_fields) # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): log.debug("moving album {}", album_id) # Manually moving and storing the album. items = list(album.items()) for item in items: item.move(store=False, with_album=False) item.store(fields=item_fields) album.move(store=False) album.store(fields=album_fields) def update_func(lib, opts, args): # Verify that the library folder exists to prevent accidental wipes. if not os.path.isdir(syspath(lib.directory)): ui.print_("Library path is unavailable or does not exist.") ui.print_(lib.directory) if not ui.input_yn("Are you sure you want to continue (y/n)?", True): return update_items( lib, args, opts.album, ui.should_move(opts.move), opts.pretend, opts.fields, opts.exclude_fields, ) update_cmd = ui.Subcommand( "update", help="update the library", aliases=( "upd", "up", ), ) update_cmd.parser.add_album_option() update_cmd.parser.add_format_option() update_cmd.parser.add_option( "-m", "--move", action="store_true", dest="move", help="move files in the library directory", ) update_cmd.parser.add_option( "-M", "--nomove", action="store_false", dest="move", help="don't move files in library", ) update_cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show all changes but do nothing", ) update_cmd.parser.add_option( "-F", "--field", default=None, action="append", dest="fields", help="list of fields to update", ) update_cmd.parser.add_option( "-e", "--exclude-field", default=None, action="append", dest="exclude_fields", help="list of fields to exclude from updates", ) update_cmd.func = update_func ================================================ FILE: beets/ui/commands/utils.py ================================================ """Utility functions for beets UI commands.""" from beets import ui def do_query(lib, query, album, also_items=True): """For commands that operate on matched items, performs a query and returns a list of matching items and a list of matching albums. (The latter is only nonempty when album is True.) Raises a UserError if no items match. also_items controls whether, when fetching albums, the associated items should be fetched also. """ if album: albums = list(lib.albums(query)) items = [] if also_items: for al in albums: items += al.items() else: albums = [] items = list(lib.items(query)) if album and not albums: raise ui.UserError("No matching albums found.") elif not album and not items: raise ui.UserError("No matching items found.") return items, albums ================================================ FILE: beets/ui/commands/version.py ================================================ """The 'version' command: show version information.""" from platform import python_version import beets from beets import plugins, ui def show_version(*args): ui.print_(f"beets version {beets.__version__}") ui.print_(f"Python version {python_version()}") # Show plugins. names = sorted(p.name for p in plugins.find_plugins()) if names: ui.print_("plugins:", ", ".join(names)) else: ui.print_("no plugins loaded") version_cmd = ui.Subcommand("version", help="output version information") version_cmd.func = show_version __all__ = ["version_cmd"] ================================================ FILE: beets/ui/commands/write.py ================================================ """The `write` command: write tag information to files.""" import os from beets import library, logging, ui from beets.util import syspath from .utils import do_query # Global logger. log = logging.getLogger("beets") def write_items(lib, query, pretend, force): """Write tag information from the database to the respective files in the filesystem. """ items, _ = do_query(lib, query, False, False) for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): log.info("missing file: {.filepath}", item) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: log.error("error reading {.filepath}: {}", item, exc) continue # Check for and display changes. changed = ui.show_model_changes( item, clean_item, library.Item._media_tag_fields, force ) if (changed or force) and not pretend: # We use `try_sync` here to keep the mtime up to date in the # database. item.try_sync(True, False) def write_func(lib, opts, args): write_items(lib, args, opts.pretend, opts.force) write_cmd = ui.Subcommand("write", help="write tag information to files") write_cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show all changes but do nothing", ) write_cmd.parser.add_option( "-f", "--force", action="store_true", help="write tags even if the existing tags match the database", ) write_cmd.func = write_func ================================================ FILE: beets/util/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Miscellaneous utility functions.""" from __future__ import annotations import errno import fnmatch import os import platform import re import shlex import shutil import subprocess import sys import tempfile import traceback from collections import Counter from collections.abc import Sequence from contextlib import suppress from enum import Enum from functools import cache from importlib import import_module from multiprocessing.pool import ThreadPool from pathlib import Path from re import Pattern from typing import ( TYPE_CHECKING, Any, AnyStr, ClassVar, Generic, NamedTuple, TypeVar, cast, ) from unidecode import unidecode import beets from beets.util import hidden if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from logging import Logger from beets.library import Item MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = "\\\\?\\" T = TypeVar("T") StrPath = str | Path PathLike = StrPath | bytes Replacements = Sequence[tuple[Pattern[str], str]] # Here for now to allow for a easy replace later on # once we can move to a PathLike (mainly used in importer) PathBytes = bytes class HumanReadableError(Exception): """An Exception that can include a human-readable error message to be logged without a traceback. Can preserve a traceback for debugging purposes as well. Has at least two fields: `reason`, the underlying exception or a string describing the problem; and `verb`, the action being performed during the error. If `tb` is provided, it is a string containing a traceback for the associated exception. (Note that this is not necessary in Python 3.x and should be removed when we make the transition.) """ error_kind = "Error" # Human-readable description of error type. def __init__(self, reason, verb, tb=None): self.reason = reason self.verb = verb self.tb = tb super().__init__(self.get_message()) def _gerund(self): """Generate a (likely) gerund form of the English verb.""" if " " in self.verb: return self.verb gerund = self.verb[:-1] if self.verb.endswith("e") else self.verb gerund += "ing" return gerund def _reasonstr(self): """Get the reason as a string.""" if isinstance(self.reason, str): return self.reason elif isinstance(self.reason, bytes): return self.reason.decode("utf-8", "ignore") elif hasattr(self.reason, "strerror"): # i.e., EnvironmentError return self.reason.strerror else: return f'"{self.reason}"' def get_message(self): """Create the human-readable description of the error, sans introduction. """ raise NotImplementedError def log(self, logger): """Log to the provided `logger` a human-readable message as an error and a verbose traceback as a debug message. """ if self.tb: logger.debug(self.tb) logger.error("{0.error_kind}: {0.args[0]}", self) class FilesystemError(HumanReadableError): """An error that occurred while performing a filesystem manipulation via a function in this module. The `paths` field is a sequence of pathnames involved in the operation. """ def __init__(self, reason, verb, paths, tb=None): self.paths = paths super().__init__(reason, verb, tb) def get_message(self): # Use a nicer English phrasing for some specific verbs. if self.verb in ("move", "copy", "rename"): clause = ( f"while {self._gerund()} {displayable_path(self.paths[0])} to" f" {displayable_path(self.paths[1])}" ) elif self.verb in ("delete", "write", "create", "read"): clause = f"while {self._gerund()} {displayable_path(self.paths[0])}" else: clause = ( f"during {self.verb} of paths" f" {', '.join(displayable_path(p) for p in self.paths)}" ) return f"{self._reasonstr()} {clause}" class MoveOperation(Enum): """The file operations that e.g. various move functions can carry out.""" MOVE = 0 COPY = 1 LINK = 2 HARDLINK = 3 REFLINK = 4 REFLINK_AUTO = 5 class PromptChoice(NamedTuple): short: str long: str callback: Any def normpath(path: PathLike) -> bytes: """Provide the canonical form of the path suitable for storing in the database. """ str_path = syspath(path, prefix=False) str_path = os.path.normpath(os.path.abspath(os.path.expanduser(str_path))) return bytestring_path(str_path) def ancestry(path: AnyStr) -> list[AnyStr]: """Return a list consisting of path's parent directory, its grandparent, and so on. For instance: >>> ancestry(b'/a/b/c') ['/', '/a', '/a/b'] The argument should *not* be the result of a call to `syspath`. """ out: list[AnyStr] = [] last_path = None while path: path = os.path.dirname(path) if path == last_path: break last_path = path if path: # don't yield '' out.insert(0, path) return out def sorted_walk( path: PathLike, ignore: Sequence[PathLike] = (), ignore_hidden: bool = False, logger: Logger | None = None, ) -> Iterator[tuple[bytes, Sequence[bytes], Sequence[bytes]]]: """Like `os.walk`, but yields things in case-insensitive sorted, breadth-first order. Directory and file names matching any glob pattern in `ignore` are skipped. If `logger` is provided, then warning messages are logged there when a directory cannot be listed. """ # Make sure the paths aren't Unicode strings. bytes_path = bytestring_path(path) ignore_bytes = [ # rename prevents mypy variable shadowing issue bytestring_path(i) for i in ignore ] # Get all the directories and files at this level. try: contents = os.listdir(syspath(bytes_path)) except OSError: if logger: logger.warning( "could not list directory {}", displayable_path(bytes_path), exc_info=True, ) return dirs = [] files = [] for str_base in contents: base = bytestring_path(str_base) # Skip ignored filenames. skip = False for pat in ignore_bytes: if fnmatch.fnmatch(base, pat): if logger: logger.debug( "ignoring '{}' due to ignore rule '{}'", base, pat ) skip = True break if skip: continue # Add to output as either a file or a directory. cur = os.path.join(bytes_path, base) if (ignore_hidden and not hidden.is_hidden(cur)) or not ignore_hidden: if os.path.isdir(syspath(cur)): dirs.append(base) else: files.append(base) # Sort lists (case-insensitive) and yield the current level. dirs.sort(key=bytes.lower) files.sort(key=bytes.lower) yield (bytes_path, dirs, files) # Recurse into directories. for base in dirs: cur = os.path.join(bytes_path, base) yield from sorted_walk(cur, ignore_bytes, ignore_hidden, logger) def path_as_posix(path: bytes) -> bytes: """Return the string representation of the path with forward (/) slashes. """ return path.replace(b"\\", b"/") def mkdirall(path: bytes): """Make all the enclosing directories of path (like mkdir -p on the parent). """ for ancestor in ancestry(path): if not os.path.isdir(syspath(ancestor)): try: os.mkdir(syspath(ancestor)) except OSError as exc: raise FilesystemError( exc, "create", (ancestor,), traceback.format_exc() ) def fnmatch_all(names: Sequence[bytes], patterns: Sequence[bytes]) -> bool: """Determine whether all strings in `names` match at least one of the `patterns`, which should be shell glob expressions. """ for name in names: matches = False for pattern in patterns: matches = fnmatch.fnmatch(name, pattern) if matches: break if not matches: return False return True def prune_dirs( path: PathLike, root: PathLike | None = None, clutter: Sequence[str] = (".DS_Store", "Thumbs.db"), ): """If path is an empty directory, then remove it. Recursively remove path's ancestry up to root (which is never removed) where there are empty directories. If path is not contained in root, then nothing is removed. Glob patterns in clutter are ignored when determining emptiness. If root is not provided, then only path may be removed (i.e., no recursive removal). """ path = normpath(path) root = normpath(root) if root else None ancestors = ancestry(path) if root is None: # Only remove the top directory. ancestors = [] elif root in ancestors: # Only remove directories below the root_bytes. ancestors = ancestors[ancestors.index(root) + 1 :] else: # Remove nothing. return bytes_clutter = [bytestring_path(c) for c in clutter] # Traverse upward from path. ancestors.append(path) ancestors.reverse() for directory in ancestors: str_directory = syspath(directory) if not os.path.exists(directory): # Directory gone already. continue match_paths = [bytestring_path(d) for d in os.listdir(str_directory)] try: if fnmatch_all(match_paths, bytes_clutter): # Directory contains only clutter (or nothing). shutil.rmtree(str_directory) else: break except OSError: break def components(path: AnyStr) -> list[AnyStr]: """Return a list of the path components in path. For instance: >>> components(b'/a/b/c') ['a', 'b', 'c'] The argument should *not* be the result of a call to `syspath`. """ comps = [] ances = ancestry(path) for anc in ances: comp = os.path.basename(anc) if comp: comps.append(comp) else: # root comps.append(anc) last = os.path.basename(path) if last: comps.append(last) return comps def bytestring_path(path: PathLike) -> bytes: """Given a path, which is either a bytes or a unicode, returns a str path (ensuring that we never deal with Unicode pathnames). Path should be bytes but has safeguards for strings to be converted. """ # Pass through bytestrings. if isinstance(path, bytes): return path str_path = str(path) # On Windows, remove the magic prefix added by `syspath`. This makes # ``bytestring_path(syspath(X)) == X``, i.e., we can safely # round-trip through `syspath`. if os.path.__name__ == "ntpath" and str_path.startswith( WINDOWS_MAGIC_PREFIX ): str_path = str_path[len(WINDOWS_MAGIC_PREFIX) :] return os.fsencode(str_path) PATH_SEP: bytes = bytestring_path(os.sep) def displayable_path( path: PathLike | Iterable[PathLike], separator: str = "; " ) -> str: """Attempts to decode a bytestring path to a unicode object for the purpose of displaying it to the user. If the `path` argument is a list or a tuple, the elements are joined with `separator`. """ if isinstance(path, (list, tuple)): return separator.join(displayable_path(p) for p in path) elif isinstance(path, str): return path elif not isinstance(path, bytes): # A non-string object: just get its unicode representation. return str(path) return os.fsdecode(path) def syspath(path: PathLike, prefix: bool = True) -> str: """Convert a path for use by the operating system. In particular, paths on Windows must receive a magic prefix and must be converted to Unicode before they are sent to the OS. To disable the magic prefix on Windows, set `prefix` to False---but only do this if you *really* know what you're doing. """ str_path = os.fsdecode(path) # Don't do anything if we're not on windows if os.path.__name__ != "ntpath": return str_path # Add the magic prefix if it isn't already there. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx if prefix and not str_path.startswith(WINDOWS_MAGIC_PREFIX): if str_path.startswith("\\\\"): # UNC path. Final path should look like \\?\UNC\... str_path = f"UNC{str_path[1:]}" str_path = f"{WINDOWS_MAGIC_PREFIX}{str_path}" return str_path def samefile(p1: bytes, p2: bytes) -> bool: """Safer equality for paths.""" if p1 == p2: return True with suppress(OSError): return os.path.samefile(syspath(p1), syspath(p2)) return False def remove(path: PathLike, soft: bool = True): """Remove the file. If `soft`, then no error will be raised if the file does not exist. """ str_path = syspath(path) if not str_path or (soft and not os.path.exists(str_path)): return try: os.remove(str_path) except OSError as exc: raise FilesystemError( exc, "delete", (str_path,), traceback.format_exc() ) def copy(path: bytes, dest: bytes, replace: bool = False): """Copy a plain file. Permissions are not copied. If `dest` already exists, raises a FilesystemError unless `replace` is True. Has no effect if `path` is the same as `dest`. Paths are translated to system paths before the syscall. """ if samefile(path, dest): return str_path = syspath(path) str_dest = syspath(dest) if not replace and os.path.exists(str_dest): raise FilesystemError("file exists", "copy", (str_path, str_dest)) try: shutil.copyfile(str_path, str_dest) except OSError as exc: raise FilesystemError( exc, "copy", (str_path, str_dest), traceback.format_exc() ) def move(path: bytes, dest: bytes, replace: bool = False): """Rename a file. `dest` may not be a directory. If `dest` already exists, raises an OSError unless `replace` is True. Has no effect if `path` is the same as `dest`. Paths are translated to system paths. """ if os.path.isdir(syspath(path)): raise FilesystemError("source is directory", "move", (path, dest)) if os.path.isdir(syspath(dest)): raise FilesystemError("destination is directory", "move", (path, dest)) if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError("file exists", "rename", (path, dest)) # First, try renaming the file. try: os.replace(syspath(path), syspath(dest)) except OSError: # Copy the file to a temporary destination. basename = os.path.basename(bytestring_path(dest)) dirname = os.path.dirname(bytestring_path(dest)) tmp = tempfile.NamedTemporaryFile( suffix=".beets", prefix=f".{os.fsdecode(basename)}.", dir=syspath(dirname), delete=False, ) try: with open(syspath(path), "rb") as f: # mypy bug: # - https://github.com/python/mypy/issues/15031 # - https://github.com/python/mypy/issues/14943 # Fix not yet released: # - https://github.com/python/mypy/pull/14975 shutil.copyfileobj(f, tmp) # type: ignore[misc] finally: tmp.close() try: # Copy file metadata shutil.copystat(syspath(path), tmp.name) except OSError: # Ignore errors because it doesn't matter too much. We may be on a # filesystem that doesn't support this. pass # Move the copied file into place. tmp_filename = tmp.name try: os.replace(tmp_filename, syspath(dest)) tmp_filename = "" os.remove(syspath(path)) except OSError as exc: raise FilesystemError( exc, "move", (path, dest), traceback.format_exc() ) finally: if tmp_filename: os.remove(tmp_filename) def link(path: bytes, dest: bytes, replace: bool = False): """Create a symbolic link from path to `dest`. Raises an OSError if `dest` already exists, unless `replace` is True. Does nothing if `path` == `dest`. """ if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: os.symlink(syspath(path), syspath(dest)) except NotImplementedError: # raised on python >= 3.2 and Windows versions before Vista raise FilesystemError( "OS does not support symbolic links.link", (path, dest), traceback.format_exc(), ) except OSError as exc: raise FilesystemError(exc, "link", (path, dest), traceback.format_exc()) def hardlink(path: bytes, dest: bytes, replace: bool = False): """Create a hard link from path to `dest`. Raises an OSError if `dest` already exists, unless `replace` is True. Does nothing if `path` == `dest`. """ if samefile(path, dest): return # Dereference symlinks, expand "~", and convert relative paths to absolute origin_path = Path(os.fsdecode(path)).expanduser().resolve() dest_path = Path(os.fsdecode(dest)).expanduser().resolve() if dest_path.exists() and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: dest_path.hardlink_to(origin_path) except NotImplementedError: raise FilesystemError( "OS does not support hard links.link", (path, dest), traceback.format_exc(), ) except OSError as exc: if exc.errno == errno.EXDEV: raise FilesystemError( "Cannot hard link across devices.link", (path, dest), traceback.format_exc(), ) else: raise FilesystemError( exc, "link", (path, dest), traceback.format_exc() ) def reflink( path: bytes, dest: bytes, replace: bool = False, fallback: bool = False, ): """Create a reflink from `dest` to `path`. Raise an `OSError` if `dest` already exists, unless `replace` is True. If `path` == `dest`, then do nothing. If `fallback` is enabled, ignore errors and copy the file instead. Otherwise, errors are re-raised as FilesystemError with an explanation. """ if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError("target exists", "rename", (path, dest)) if fallback: with suppress(Exception): return import_module("reflink").reflink(path, dest) return copy(path, dest, replace) try: import_module("reflink").reflink(path, dest) except (ImportError, OSError): raise except Exception as exc: msg = { "EXDEV": "Cannot reflink across devices", "EOPNOTSUPP": "Device does not support reflinks", }.get(str(exc), "OS does not support reflinks") raise FilesystemError( msg, "reflink", (path, dest), traceback.format_exc() ) from exc def unique_path(path: bytes) -> bytes: """Returns a version of ``path`` that does not exist on the filesystem. Specifically, if ``path` itself already exists, then something unique is appended to the path. """ if not os.path.exists(syspath(path)): return path base, ext = os.path.splitext(path) match = re.search(rb"\.(\d)+$", base) if match: num = int(match.group(1)) base = base[: match.start()] else: num = 0 while True: num += 1 suffix = f".{num}".encode() + ext new_path = base + suffix if not os.path.exists(new_path): return new_path # Note: The Windows "reserved characters" are, of course, allowed on # Unix. They are forbidden here because they cause problems on Samba # shares, which are sufficiently common as to cause frequent problems. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx CHAR_REPLACE = [ (re.compile(r"[\\/]"), "_"), # / and \ -- forbidden everywhere. (re.compile(r"^\."), "_"), # Leading dot (hidden files on Unix). (re.compile(r"[\x00-\x1f]"), ""), # Control characters. (re.compile(r'[<>:"\?\*\|]'), "_"), # Windows "reserved characters". (re.compile(r"\.$"), "_"), # Trailing dots. (re.compile(r"\s+$"), ""), # Trailing whitespace. ] def sanitize_path(path: str, replacements: Replacements | None = None) -> str: """Takes a path (as a Unicode string) and makes sure that it is legal. Returns a new path. Only works with fragments; won't work reliably on Windows when a path begins with a drive letter. Path separators (including altsep!) should already be cleaned from the path components. If replacements is specified, it is used *instead* of the default set of replacements; it must be a list of (compiled regex, replacement string) pairs. """ replacements = replacements or CHAR_REPLACE comps = components(path) if not comps: return "" for i, comp in enumerate(comps): for regex, repl in replacements: comp = regex.sub(repl, comp) comps[i] = comp return os.path.join(*comps) def truncate_str(s: str, length: int) -> str: """Truncate the string to the given byte length. If we end up truncating a unicode character in the middle (rendering it invalid), it is removed: >>> s = "🎹🎶" # 8 bytes >>> truncate_str(s, 6) '🎹' """ return os.fsencode(s)[:length].decode(sys.getfilesystemencoding(), "ignore") def truncate_path(str_path: str) -> str: """Truncate each path part to a legal length preserving the extension.""" max_length = get_max_filename_length() path = Path(str_path) parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]] stem = truncate_str(path.stem, max_length - len(path.suffix)) return f"{Path(*parent_parts, stem)}{path.suffix}" def _legalize_stage( path: str, replacements: Replacements | None, extension: str ) -> tuple[str, bool]: """Perform a single round of path legalization steps 1. sanitation/replacement 2. appending the extension 3. truncation. Return the path and whether truncation was required. """ # Perform an initial sanitization including user replacements. path = sanitize_path(path, replacements) # Preserve extension. path += extension.lower() # Truncate too-long components. pre_truncate_path = path path = truncate_path(path) return path, path != pre_truncate_path def legalize_path( path: str, replacements: Replacements | None, extension: str ) -> tuple[str, bool]: """Given a path-like Unicode string, produce a legal path. Return the path and a flag indicating whether some replacements had to be ignored (see below). This function uses `_legalize_stage` function to legalize the path, see its documentation for the details of what this involves. It is called up to three times in case truncation conflicts with replacements (as can happen when truncation creates whitespace at the end of the string, for example). The limited number of iterations avoids the possibility of an infinite loop of sanitation and truncation operations, which could be caused by replacement rules that make the string longer. The flag returned from this function indicates that the path has to be truncated twice (indicating that replacements made the string longer again after it was truncated); the application should probably log some sort of warning. """ suffix = as_string(extension) first_stage, _ = os.path.splitext( _legalize_stage(path, replacements, suffix)[0] ) # Re-sanitize following truncation (including user replacements). second_stage, truncated = _legalize_stage(first_stage, replacements, suffix) if not truncated: return second_stage, False # If the path was truncated, discard user replacements # and run through one last legalization stage. return _legalize_stage(first_stage, None, suffix)[0], True def str2bool(value: str) -> bool: """Returns a boolean reflecting a human-entered string.""" return value.lower() in ("yes", "1", "true", "t", "y") def as_string(value: Any) -> str: """Convert a value to a Unicode object for matching with a query. None becomes the empty string. Bytestrings are silently decoded. """ if value is None: return "" elif isinstance(value, memoryview): return bytes(value).decode("utf-8", "ignore") elif isinstance(value, bytes): return value.decode("utf-8", "ignore") else: return str(value) def plurality(objs: Iterable[T]) -> tuple[T, int]: """Given a sequence of hashble objects, returns the object that is most common in the set and the its number of appearance. The sequence must contain at least one object. """ c = Counter(objs) if not c: raise ValueError("sequence must be non-empty") return c.most_common(1)[0] def get_most_common_tags( items: Sequence[Item], ) -> tuple[dict[str, Any], dict[str, Any]]: """Extract the likely current metadata for an album given a list of its items. Return two dictionaries: - The most common value for each field. - Whether each field's value was unanimous (values are booleans). """ assert items # Must be nonempty. likelies = {} consensus = {} fields = [ "artist", "album", "albumartist", "year", "disctotal", "mb_albumid", "label", "barcode", "catalognum", "country", "media", "albumdisambig", "data_source", ] for field in fields: values = [item.get(field) for item in items if item] likelies[field], freq = plurality(values) consensus[field] = freq == len(values) # If there's an album artist consensus, use this for the artist. if consensus["albumartist"] and likelies["albumartist"]: likelies["artist"] = likelies["albumartist"] return likelies, consensus # stdout and stderr as bytes class CommandOutput(NamedTuple): stdout: bytes stderr: bytes def command_output( cmd: list[str] | list[bytes], shell: bool = False ) -> CommandOutput: """Runs the command and returns its output after it has exited. Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain byte strings of the respective output streams. ``cmd`` is a list of arguments starting with the command names. The arguments are bytes on Unix and strings on Windows. If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a shell to execute. If the process exits with a non-zero return code ``subprocess.CalledProcessError`` is raised. May also raise ``OSError``. This replaces `subprocess.check_output` which can have problems if lots of output is sent to stderr. """ devnull = subprocess.DEVNULL proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=devnull, close_fds=platform.system() != "Windows", shell=shell, ) stdout, stderr = proc.communicate() if proc.returncode: raise subprocess.CalledProcessError( returncode=proc.returncode, cmd=" ".join(map(os.fsdecode, cmd)), output=stdout + stderr, ) return CommandOutput(stdout, stderr) @cache def get_max_filename_length() -> int: """Attempt to determine the maximum filename length for the filesystem containing `path`. If the value is greater than `limit`, then `limit` is used instead (to prevent errors when a filesystem misreports its capacity). If it cannot be determined (e.g., on Windows), return `limit`. """ if length := beets.config["max_filename_length"].get(int): return length limit = MAX_FILENAME_LENGTH if hasattr(os, "statvfs"): try: res = os.statvfs(beets.config["directory"].as_str()) except OSError: return limit return min(res[9], limit) else: return limit def open_anything() -> str: """Return the system command that dispatches execution to the correct program. """ sys_name = platform.system() if sys_name == "Darwin": base_cmd = "open" elif sys_name == "Windows": # `start` is a cmd.exe builtin, so invoke it through the shell. base_cmd = 'cmd /c start ""' else: # Assume Unix base_cmd = "xdg-open" return base_cmd def editor_command() -> str: """Get a command for opening a text file. First try environment variable `VISUAL` followed by `EDITOR`. As last resort fall back to `open_anything()`, the platform-specific tool for opening files in general. """ return ( os.environ.get("VISUAL") or os.environ.get("EDITOR") or open_anything() ) def interactive_open(targets: Sequence[str], command: str): """Open the files in `targets` by `exec`ing a new `command`, given as a Unicode string. (The new program takes over, and Python execution ends: this does not fork a subprocess.) Can raise `OSError`. """ assert command # Split the command string into its arguments. try: args = shlex.split(command) except ValueError: # Malformed shell tokens. args = [command] args.insert(0, args[0]) # for argv[0] args += targets return os.execlp(*args) def case_sensitive(path: bytes) -> bool: """Check whether the filesystem at the given path is case sensitive. To work best, the path should point to a file or a directory. If the path does not exist, assume a case sensitive file system on every platform except Windows. Currently only used for absolute paths by beets; may have a trailing path separator. """ # Look at parent paths until we find a path that actually exists, or # reach the root. while True: head, tail = os.path.split(path) if head == path: # We have reached the root of the file system. # By default, the case sensitivity depends on the platform. return platform.system() != "Windows" # Trailing path separator, or path does not exist. if not tail or not os.path.exists(path): path = head continue upper_tail = tail.upper() lower_tail = tail.lower() # In case we can't tell from the given path name, look at the # parent directory. if upper_tail == lower_tail: path = head continue upper_sys = syspath(os.path.join(head, upper_tail)) lower_sys = syspath(os.path.join(head, lower_tail)) # If either the upper-cased or lower-cased path does not exist, the # filesystem must be case-sensitive. # (Otherwise, we have more work to do.) if not os.path.exists(upper_sys) or not os.path.exists(lower_sys): return True # Original and both upper- and lower-cased versions of the path # exist on the file system. Check whether they refer to different # files by their inodes (or an alternative method on Windows). return not os.path.samefile(lower_sys, upper_sys) def asciify_path(path: str, sep_replace: str) -> str: """Decodes all unicode characters in a path into ASCII equivalents. Substitutions are provided by the unidecode module. Path separators in the input are preserved. Keyword arguments: path -- The path to be asciified. sep_replace -- the string to be used to replace extraneous path separators. """ # if this platform has an os.altsep, change it to os.sep. if os.altsep: path = path.replace(os.altsep, os.sep) path_components: list[str] = path.split(os.sep) for index, item in enumerate(path_components): path_components[index] = unidecode(item).replace(os.sep, sep_replace) if os.altsep: path_components[index] = unidecode(item).replace( os.altsep, sep_replace ) return os.sep.join(path_components) def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None: """Apply the function `transform` to all the elements in the iterable `items`, like `map(transform, items)` but with no return value. The parallelism uses threads (not processes), so this is only useful for IO-bound `transform`s. """ pool = ThreadPool() pool.map(transform, items) pool.close() pool.join() class cached_classproperty(Generic[T]): """Descriptor implementing cached class properties. Provides class-level dynamic property behavior where the getter function is called once per class and the result is cached for subsequent access. Unlike instance properties, this operates on the class rather than instances. """ cache: ClassVar[dict[tuple[type[object], str], object]] = {} name: str = "" # Ideally, we would like to use `Callable[[type[T]], Any]` here, # however, `mypy` is unable to see this as a **class** property, and thinks # that this callable receives an **instance** of the object, failing the # type check, for example: # >>> class Album: # >>> @cached_classproperty # >>> def foo(cls): # >>> reveal_type(cls) # mypy: revealed type is "Album" # >>> return cls.bar # # Argument 1 to "cached_classproperty" has incompatible type # "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]" # # Therefore, we just use `Any` here, which is not ideal, but works. def __init__(self, getter: Callable[..., T]) -> None: """Initialize the descriptor with the property getter function.""" self.getter: Callable[..., T] = getter def __set_name__(self, owner: object, name: str) -> None: """Capture the attribute name this descriptor is assigned to.""" self.name = name def __get__(self, instance: object, owner: type[object]) -> T: """Compute and cache if needed, and return the property value.""" key: tuple[type[object], str] = owner, self.name if key not in self.cache: self.cache[key] = self.getter(owner) return cast(T, self.cache[key]) class LazySharedInstance(Generic[T]): """A descriptor that provides access to a lazily-created shared instance of the containing class, while calling the class constructor to construct a new object works as usual. ``` ID: int = 0 class Foo: def __init__(): global ID self.id = ID ID += 1 def func(self): print(self.id) shared: LazySharedInstance[Foo] = LazySharedInstance() a0 = Foo() a1 = Foo.shared a2 = Foo() a3 = Foo.shared a0.func() # 0 a1.func() # 1 a2.func() # 2 a3.func() # 1 ``` """ _instance: T | None = None def __get__(self, instance: T | None, owner: type[T]) -> T: if instance is not None: raise RuntimeError( "shared instances must be obtained from the class property, " "not an instance" ) if self._instance is None: self._instance = owner() return self._instance def get_module_tempdir(module: str) -> Path: """Return the temporary directory for the given module. The directory is created within the `/tmp/beets/` directory on Linux (or the equivalent temporary directory on other systems). Dots in the module name are replaced by underscores. """ module = module.replace("beets.", "").replace(".", "_") return Path(tempfile.gettempdir()) / "beets" / module def clean_module_tempdir(module: str) -> None: """Clean the temporary directory for the given module.""" tempdir = get_module_tempdir(module) shutil.rmtree(tempdir, ignore_errors=True) with suppress(OSError): # remove parent (/tmp/beets) directory if it is empty tempdir.parent.rmdir() def get_temp_filename( module: str, prefix: str = "", path: PathLike | None = None, suffix: str = "", ) -> bytes: """Return temporary filename for the given module and prefix. The filename starts with the given `prefix`. If 'suffix' is given, it is used a the file extension. If 'path' is given, we use the same suffix. """ if not suffix and path: suffix = Path(os.fsdecode(path)).suffix tempdir = get_module_tempdir(module) tempdir.mkdir(parents=True, exist_ok=True) descriptor, filename = tempfile.mkstemp( dir=tempdir, prefix=prefix, suffix=suffix ) os.close(descriptor) return bytestring_path(filename) def unique_list(elements: Iterable[T]) -> list[T]: """Return a list with unique elements in the original order.""" return list(dict.fromkeys(elements)) ================================================ FILE: beets/util/artresizer.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Abstraction layer to resize images using PIL, ImageMagick, or a public resizing proxy if neither is available. """ from __future__ import annotations import os import os.path import platform import re import subprocess from abc import ABC, abstractmethod from contextlib import suppress from enum import Enum from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlencode from beets import logging, util from beets.util import ( LazySharedInstance, displayable_path, get_temp_filename, syspath, ) if TYPE_CHECKING: from collections.abc import Mapping PROXY_URL = "https://images.weserv.nl/" log = logging.getLogger("beets") def resize_url(url: str, maxwidth: int, quality: int = 0) -> str: """Return a proxied image URL that resizes the original image to maxwidth (preserving aspect ratio). """ params = { "url": url.replace("http://", ""), "w": maxwidth, } if quality > 0: params["q"] = quality return f"{PROXY_URL}?{urlencode(params)}" class LocalBackendNotAvailableError(Exception): pass # Singleton pattern that the typechecker understands: # https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions class NotAvailable(Enum): token = 0 _NOT_AVAILABLE = NotAvailable.token class LocalBackend(ABC): NAME: ClassVar[str] @classmethod @abstractmethod def version(cls) -> Any: """Return the backend version if its dependencies are satisfied or raise `LocalBackendNotAvailableError`. """ pass @classmethod def available(cls) -> bool: """Return `True` this backend's dependencies are satisfied and it can be used, `False` otherwise.""" try: cls.version() return True except LocalBackendNotAvailableError: return False @abstractmethod def resize( self, maxwidth: int, path_in: bytes, path_out: bytes | None = None, quality: int = 0, max_filesize: int = 0, ) -> bytes: """Resize an image to the given width and return the output path. On error, logs a warning and returns `path_in`. """ pass @abstractmethod def get_size(self, path_in: bytes) -> tuple[int, int] | None: """Return the (width, height) of the image or None if unavailable.""" pass @abstractmethod def deinterlace( self, path_in: bytes, path_out: bytes | None = None, ) -> bytes: """Remove interlacing from an image and return the output path. On error, logs a warning and returns `path_in`. """ pass @abstractmethod def get_format(self, path_in: bytes) -> str | None: """Return the image format (e.g., 'PNG') or None if undetectable.""" pass @abstractmethod def convert_format( self, source: bytes, target: bytes, deinterlaced: bool, ) -> bytes: """Convert an image to a new format and return the new file path. On error, logs a warning and returns `source`. """ pass @property def can_compare(self) -> bool: """Indicate whether image comparison is supported by this backend.""" return False def compare( self, im1: bytes, im2: bytes, compare_threshold: float, ) -> bool | None: """Compare two images and return `True` if they are similar enough, or `None` if there is an error. This must only be called if `self.can_compare()` returns `True`. """ # It is an error to call this when ArtResizer.can_compare is not True. raise NotImplementedError() @property def can_write_metadata(self) -> bool: """Indicate whether writing metadata to images is supported.""" return False def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None: """Write key-value metadata into the image file. This must only be called if `self.can_write_metadata()` returns `True`. """ # It is an error to call this when ArtResizer.can_write_metadata is not True. raise NotImplementedError() class IMBackend(LocalBackend): NAME = "ImageMagick" # These fields are used as a cache for `version()`. `_legacy` indicates # whether the modern `magick` binary is available or whether to fall back # to the old-style `convert`, `identify`, etc. commands. _version: tuple[int, int, int] | NotAvailable | None = None _legacy: bool | None = None @classmethod def version(cls) -> tuple[int, int, int]: """Obtain and cache ImageMagick version. Raises `LocalBackendNotAvailableError` if not available. """ if cls._version is None: for cmd_name, legacy in (("magick", False), ("convert", True)): try: out = util.command_output([cmd_name, "--version"]).stdout except (subprocess.CalledProcessError, OSError) as exc: log.debug("ImageMagick version check failed: {}", exc) cls._version = _NOT_AVAILABLE else: if b"imagemagick" in out.lower(): pattern = rb".+ (\d+)\.(\d+)\.(\d+).*" match = re.search(pattern, out) if match: cls._version = ( int(match.group(1)), int(match.group(2)), int(match.group(3)), ) cls._legacy = legacy # cls._version is never None here, but mypy doesn't get that if cls._version is _NOT_AVAILABLE or cls._version is None: raise LocalBackendNotAvailableError() else: return cls._version convert_cmd: list[str] identify_cmd: list[str] compare_cmd: list[str] def __init__(self) -> None: """Initialize a wrapper around ImageMagick for local image operations. Stores the ImageMagick version and legacy flag. If ImageMagick is not available, raise an Exception. """ self.version() # Use ImageMagick's magick binary when it's available. # If it's not, fall back to the older, separate convert # and identify commands. if self._legacy: self.convert_cmd = ["convert"] self.identify_cmd = ["identify"] self.compare_cmd = ["compare"] else: self.convert_cmd = ["magick"] self.identify_cmd = ["magick", "identify"] self.compare_cmd = ["magick", "compare"] def resize( self, maxwidth: int, path_in: bytes, path_out: bytes | None = None, quality: int = 0, max_filesize: int = 0, ) -> bytes: """Resize using ImageMagick. Use the ``magick`` program or ``convert`` on older versions. Return the output path of resized image. """ if not path_out: path_out = get_temp_filename(__name__, "resize_IM_", path_in) log.debug( "artresizer: ImageMagick resizing {} to {}", displayable_path(path_in), displayable_path(path_out), ) # "-resize WIDTHx>" shrinks images with the width larger # than the given width while maintaining the aspect ratio # with regards to the height. # ImageMagick already seems to default to no interlace, but we include # it here for the sake of explicitness. cmd: list[str] = [ *self.convert_cmd, syspath(path_in, prefix=False), "-resize", f"{maxwidth}x>", "-interlace", "none", ] if quality > 0: cmd += ["-quality", f"{quality}"] # "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick # to SIZE in bytes. if max_filesize > 0: cmd += ["-define", f"jpeg:extent={max_filesize}b"] cmd.append(syspath(path_out, prefix=False)) try: util.command_output(cmd) except subprocess.CalledProcessError: log.warning( "artresizer: IM convert failed for {}", displayable_path(path_in), ) return path_in return path_out def get_size(self, path_in: bytes) -> tuple[int, int] | None: cmd: list[str] = [ *self.identify_cmd, "-format", "%w %h", syspath(path_in, prefix=False), ] try: out = util.command_output(cmd).stdout except subprocess.CalledProcessError as exc: log.warning("ImageMagick size query failed") log.debug( "`convert` exited with (status {.returncode}) when " "getting size with command {}:\n{}", exc, cmd, exc.output.strip(), ) return None try: size = tuple(map(int, out.split(b" "))) except IndexError: log.warning("Could not understand IM output: {0!r}", out) return None if len(size) != 2: log.warning("Could not understand IM output: {0!r}", out) return None return size def deinterlace( self, path_in: bytes, path_out: bytes | None = None, ) -> bytes: if not path_out: path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in) cmd = [ *self.convert_cmd, syspath(path_in, prefix=False), "-interlace", "none", syspath(path_out, prefix=False), ] try: util.command_output(cmd) return path_out except subprocess.CalledProcessError: # FIXME: Should probably issue a warning? return path_in def get_format(self, path_in: bytes) -> str | None: cmd = [*self.identify_cmd, "-format", "%[magick]", syspath(path_in)] try: # Image formats should really only be ASCII strings such as "PNG", # if anything else is returned, something is off and we return # None for safety. return util.command_output(cmd).stdout.decode("ascii", "strict") except (subprocess.CalledProcessError, UnicodeError): # FIXME: Should probably issue a warning? return None def convert_format( self, source: bytes, target: bytes, deinterlaced: bool, ) -> bytes: cmd = [ *self.convert_cmd, syspath(source), *(["-interlace", "none"] if deinterlaced else []), syspath(target), ] try: subprocess.check_call( cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL ) return target except subprocess.CalledProcessError: # FIXME: Should probably issue a warning? return source @property def can_compare(self) -> bool: return self.version() > (6, 8, 7) def compare( self, im1: bytes, im2: bytes, compare_threshold: float, ) -> bool | None: is_windows = platform.system() == "Windows" # Converting images to grayscale tends to minimize the weight # of colors in the diff score. So we first convert both images # to grayscale and then pipe them into the `compare` command. # On Windows, ImageMagick doesn't support the magic \\?\ prefix # on paths, so we pass `prefix=False` to `syspath`. convert_cmd = [ *self.convert_cmd, syspath(im2, prefix=False), syspath(im1, prefix=False), "-colorspace", "gray", "MIFF:-", ] compare_cmd = [ *self.compare_cmd, "-define", "phash:colorspaces=sRGB,HCLp", "-metric", "PHASH", "-", "null:", ] log.debug( "comparing images with pipeline {} | {}", convert_cmd, compare_cmd ) convert_proc = subprocess.Popen( convert_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=not is_windows, ) compare_proc = subprocess.Popen( compare_cmd, stdin=convert_proc.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=not is_windows, ) # help out mypy assert convert_proc.stdout is not None assert convert_proc.stderr is not None # Check the convert output. We're not interested in the # standard output; that gets piped to the next stage. convert_proc.stdout.close() convert_stderr = convert_proc.stderr.read() convert_proc.stderr.close() convert_proc.wait() if convert_proc.returncode: log.debug( "ImageMagick convert failed with status {.returncode}: {!r}", convert_proc, convert_stderr, ) return None # Check the compare output. stdout, stderr = compare_proc.communicate() if compare_proc.returncode: if compare_proc.returncode != 1: log.debug( "ImageMagick compare failed: {}, {}", displayable_path(im2), displayable_path(im1), ) return None out_str = stderr else: out_str = stdout # ImageMagick 7.1.1-44 outputs in a different format. if b"(" in out_str and out_str.endswith(b")"): # Extract diff from "... (diff)". out_str = out_str[out_str.index(b"(") + 1 : -1] try: phash_diff = float(out_str) except ValueError: log.debug("IM output is not a number: {0!r}", out_str) return None log.debug("ImageMagick compare score: {}", phash_diff) return phash_diff <= compare_threshold @property def can_write_metadata(self) -> bool: return True def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None: assignments = chain.from_iterable( ("-set", k, v) for k, v in metadata.items() ) str_file = os.fsdecode(file) command = [*self.convert_cmd, str_file, *assignments, str_file] util.command_output(command) class PILBackend(LocalBackend): NAME = "PIL" @classmethod def version(cls) -> None: try: __import__("PIL", fromlist=["Image"]) except ImportError: raise LocalBackendNotAvailableError() def __init__(self) -> None: """Initialize a wrapper around PIL for local image operations. If PIL is not available, raise an Exception. """ self.version() def resize( self, maxwidth: int, path_in: bytes, path_out: bytes | None = None, quality: int = 0, max_filesize: int = 0, ) -> bytes: """Resize using Python Imaging Library (PIL). Return the output path of resized image. """ if not path_out: path_out = get_temp_filename(__name__, "resize_PIL_", path_in) from PIL import Image log.debug( "artresizer: PIL resizing {} to {}", displayable_path(path_in), displayable_path(path_out), ) try: im = Image.open(syspath(path_in)) size = maxwidth, maxwidth im.thumbnail(size, Image.Resampling.LANCZOS) if quality == 0: # Use PIL's default quality. quality = -1 # progressive=False only affects JPEGs and is the default, # but we include it here for explicitness. im.save(os.fsdecode(path_out), quality=quality, progressive=False) if max_filesize > 0: # If maximum filesize is set, we attempt to lower the quality # of jpeg conversion by a proportional amount, up to 3 attempts # First, set the maximum quality to either provided, or 95 if quality > 0: lower_qual = quality else: lower_qual = 95 for i in range(5): # 5 attempts is an arbitrary choice filesize = os.stat(syspath(path_out)).st_size log.debug("PIL Pass {} : Output size: {}B", i, filesize) if filesize <= max_filesize: return path_out # The relationship between filesize & quality will be # image dependent. lower_qual -= 10 # Restrict quality dropping below 10 if lower_qual < 10: lower_qual = 10 # Use optimize flag to improve filesize decrease im.save( os.fsdecode(path_out), quality=lower_qual, optimize=True, progressive=False, ) log.warning( "PIL Failed to resize file to below {}B", max_filesize ) return path_out else: return path_out except OSError: log.error( "PIL cannot create thumbnail for '{}'", displayable_path(path_in), ) return path_in def get_size(self, path_in: bytes) -> tuple[int, int] | None: from PIL import Image try: im = Image.open(syspath(path_in)) return im.size except OSError as exc: log.error( "PIL could not read file {}: {}", displayable_path(path_in), exc ) return None def deinterlace( self, path_in: bytes, path_out: bytes | None = None, ) -> bytes: if not path_out: path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in) from PIL import Image try: im = Image.open(syspath(path_in)) im.save(os.fsdecode(path_out), progressive=False) return path_out except OSError: # FIXME: Should probably issue a warning? return path_in def get_format(self, path_in: bytes) -> str | None: from PIL import Image, UnidentifiedImageError try: with Image.open(syspath(path_in)) as im: return im.format except ( ValueError, TypeError, UnidentifiedImageError, FileNotFoundError, ): log.exception("failed to detect image format for {}", path_in) return None def convert_format( self, source: bytes, target: bytes, deinterlaced: bool, ) -> bytes: from PIL import Image, UnidentifiedImageError try: with Image.open(syspath(source)) as im: im.save(os.fsdecode(target), progressive=not deinterlaced) return target except ( ValueError, TypeError, UnidentifiedImageError, FileNotFoundError, OSError, ): log.exception("failed to convert image {} -> {}", source, target) return source @property def can_compare(self) -> bool: return False def compare( self, im1: bytes, im2: bytes, compare_threshold: float, ) -> bool | None: # It is an error to call this when ArtResizer.can_compare is not True. raise NotImplementedError() @property def can_write_metadata(self) -> bool: return True def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None: from PIL import Image, PngImagePlugin # FIXME: Detect and handle other file types (currently, the only user # is the thumbnails plugin, which generates PNG images). im = Image.open(syspath(file)) meta = PngImagePlugin.PngInfo() for k, v in metadata.items(): meta.add_text(k, v, zip=False) im.save(os.fsdecode(file), "PNG", pnginfo=meta) BACKEND_CLASSES: list[type[LocalBackend]] = [ IMBackend, PILBackend, ] class ArtResizer: """A class that dispatches image operations to an available backend.""" local_method: LocalBackend | None def __init__(self) -> None: """Create a resizer object with an inferred method.""" # Check if a local backend is available, and store an instance of the # backend class. Otherwise, fallback to the web proxy. for backend_cls in BACKEND_CLASSES: try: self.local_method = backend_cls() log.debug("artresizer: method is {.local_method.NAME}", self) break except LocalBackendNotAvailableError: continue else: # FIXME: Turn WEBPROXY into a backend class as well to remove all # the special casing. Then simply delegate all methods to the # backends. (How does proxy_url fit in here, however?) # Use an ABC (or maybe a typing Protocol?) for backend # methods, such that both individual backends as well as # ArtResizer implement it. # It should probably be configurable which backends classes to # consider, similar to fetchart or lyrics backends (i.e. a list # of backends sorted by priority). log.debug("artresizer: method is WEBPROXY") self.local_method = None shared: LazySharedInstance[ArtResizer] = LazySharedInstance() @property def method(self) -> str: if self.local_method is not None: return self.local_method.NAME else: return "WEBPROXY" def resize( self, maxwidth: int, path_in: bytes, path_out: bytes | None = None, quality: int = 0, max_filesize: int = 0, ) -> bytes: """Manipulate an image file according to the method, returning a new path. For PIL or IMAGEMAGIC methods, resizes the image to a temporary file and encodes with the specified quality level. For WEBPROXY, returns `path_in` unmodified. """ if self.local_method is not None: return self.local_method.resize( maxwidth, path_in, path_out, quality=quality, max_filesize=max_filesize, ) else: # Handled by `proxy_url` already. return path_in def deinterlace( self, path_in: bytes, path_out: bytes | None = None, ) -> bytes: """Deinterlace an image. Only available locally. """ if self.local_method is not None: return self.local_method.deinterlace(path_in, path_out) else: # FIXME: Should probably issue a warning? return path_in def proxy_url(self, maxwidth: int, url: str, quality: int = 0) -> str: """Modifies an image URL according the method, returning a new URL. For WEBPROXY, a URL on the proxy server is returned. Otherwise, the URL is returned unmodified. """ if self.local: # Going to be handled by `resize()`. return url else: return resize_url(url, maxwidth, quality) @property def local(self) -> bool: """A boolean indicating whether the resizing method is performed locally (i.e., PIL or ImageMagick). """ return self.local_method is not None def get_size(self, path_in: bytes) -> tuple[int, int] | None: """Return the size of an image file as an int couple (width, height) in pixels. Only available locally. """ if self.local_method is not None: return self.local_method.get_size(path_in) else: raise RuntimeError( "image cannot be obtained without artresizer backend" ) def get_format(self, path_in: bytes) -> str | None: """Returns the format of the image as a string. Only available locally. """ if self.local_method is not None: return self.local_method.get_format(path_in) else: # FIXME: Should probably issue a warning? return None def reformat( self, path_in: bytes, new_format: str, deinterlaced: bool = True, ) -> bytes: """Converts image to desired format, updating its extension, but keeping the same filename. Only available locally. """ if self.local_method is None: # FIXME: Should probably issue a warning? return path_in new_format = new_format.lower() # A nonexhaustive map of image "types" to extensions overrides new_format = { "jpeg": "jpg", }.get(new_format, new_format) fname, _ = os.path.splitext(path_in) path_new = fname + b"." + new_format.encode("utf8") # allows the exception to propagate, while still making sure a changed # file path was removed result_path = path_in try: result_path = self.local_method.convert_format( path_in, path_new, deinterlaced ) finally: if result_path != path_in: with suppress(OSError): os.unlink(path_in) return result_path @property def can_compare(self) -> bool: """A boolean indicating whether image comparison is available""" if self.local_method is not None: return self.local_method.can_compare else: return False def compare( self, im1: bytes, im2: bytes, compare_threshold: float, ) -> bool | None: """Return a boolean indicating whether two images are similar. Only available locally. """ if self.local_method is not None: return self.local_method.compare(im1, im2, compare_threshold) else: # FIXME: Should probably issue a warning? return None @property def can_write_metadata(self) -> bool: """A boolean indicating whether writing image metadata is supported.""" if self.local_method is not None: return self.local_method.can_write_metadata else: return False def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None: """Write key-value metadata to the image file. Only available locally. Currently, expects the image to be a PNG file. """ if self.local_method is not None: self.local_method.write_metadata(file, metadata) else: # FIXME: Should probably issue a warning? pass ================================================ FILE: beets/util/bluelet.py ================================================ """Extremely simple pure-Python implementation of coroutine-style asynchronous socket I/O. Inspired by, but inferior to, Eventlet. Bluelet can also be thought of as a less-terrible replacement for asyncore. Bluelet: easy concurrency without all the messy parallelism. """ import collections import errno import select import socket import sys import time import traceback import types # Basic events used for thread scheduling. class Event: """Just a base class identifying Bluelet events. An event is an object yielded from a Bluelet thread coroutine to suspend operation and communicate with the scheduler. """ pass class WaitableEvent(Event): """A waitable event is one encapsulating an action that can be waited for using a select() call. That is, it's an event with an associated file descriptor. """ def waitables(self): """Return "waitable" objects to pass to select(). Should return three iterables for input readiness, output readiness, and exceptional conditions (i.e., the three lists passed to select()). """ return (), (), () def fire(self): """Called when an associated file descriptor becomes ready (i.e., is returned from a select() call). """ pass class ValueEvent(Event): """An event that does nothing but return a fixed value.""" def __init__(self, value): self.value = value class ExceptionEvent(Event): """Raise an exception at the yield point. Used internally.""" def __init__(self, exc_info): self.exc_info = exc_info class SpawnEvent(Event): """Add a new coroutine thread to the scheduler.""" def __init__(self, coro): self.spawned = coro class JoinEvent(Event): """Suspend the thread until the specified child thread has completed. """ def __init__(self, child): self.child = child class KillEvent(Event): """Unschedule a child thread.""" def __init__(self, child): self.child = child class DelegationEvent(Event): """Suspend execution of the current thread, start a new thread and, once the child thread finished, return control to the parent thread. """ def __init__(self, coro): self.spawned = coro class ReturnEvent(Event): """Return a value the current thread's delegator at the point of delegation. Ends the current (delegate) thread. """ def __init__(self, value): self.value = value class SleepEvent(WaitableEvent): """Suspend the thread for a given duration.""" def __init__(self, duration): self.wakeup_time = time.time() + duration def time_left(self): return max(self.wakeup_time - time.time(), 0.0) class ReadEvent(WaitableEvent): """Reads from a file-like object.""" def __init__(self, fd, bufsize): self.fd = fd self.bufsize = bufsize def waitables(self): return (self.fd,), (), () def fire(self): return self.fd.read(self.bufsize) class WriteEvent(WaitableEvent): """Writes to a file-like object.""" def __init__(self, fd, data): self.fd = fd self.data = data def waitable(self): return (), (self.fd,), () def fire(self): self.fd.write(self.data) # Core logic for executing and scheduling threads. def _event_select(events): """Perform a select() over all the Events provided, returning the ones ready to be fired. Only WaitableEvents (including SleepEvents) matter here; all other events are ignored (and thus postponed). """ # Gather waitables and wakeup times. waitable_to_event = {} rlist, wlist, xlist = [], [], [] earliest_wakeup = None for event in events: if isinstance(event, SleepEvent): if not earliest_wakeup: earliest_wakeup = event.wakeup_time else: earliest_wakeup = min(earliest_wakeup, event.wakeup_time) elif isinstance(event, WaitableEvent): r, w, x = event.waitables() rlist += r wlist += w xlist += x for waitable in r: waitable_to_event[("r", waitable)] = event for waitable in w: waitable_to_event[("w", waitable)] = event for waitable in x: waitable_to_event[("x", waitable)] = event # If we have a any sleeping threads, determine how long to sleep. if earliest_wakeup: timeout = max(earliest_wakeup - time.time(), 0.0) else: timeout = None # Perform select() if we have any waitables. if rlist or wlist or xlist: rready, wready, xready = select.select(rlist, wlist, xlist, timeout) else: rready, wready, xready = (), (), () if timeout: time.sleep(timeout) # Gather ready events corresponding to the ready waitables. ready_events = set() for ready in rready: ready_events.add(waitable_to_event[("r", ready)]) for ready in wready: ready_events.add(waitable_to_event[("w", ready)]) for ready in xready: ready_events.add(waitable_to_event[("x", ready)]) # Gather any finished sleeps. for event in events: if isinstance(event, SleepEvent) and event.time_left() == 0.0: ready_events.add(event) return ready_events class ThreadError(Exception): def __init__(self, coro, exc_info): self.coro = coro self.exc_info = exc_info def reraise(self): raise self.exc_info[1].with_traceback(self.exc_info[2]) SUSPENDED = Event() # Special sentinel placeholder for suspended threads. class Delegated(Event): """Placeholder indicating that a thread has delegated execution to a different thread. """ def __init__(self, child): self.child = child def run(root_coro): """Schedules a coroutine, running it to completion. This encapsulates the Bluelet scheduler, which the root coroutine can add to by spawning new coroutines. """ # The "threads" dictionary keeps track of all the currently- # executing and suspended coroutines. It maps coroutines to their # currently "blocking" event. The event value may be SUSPENDED if # the coroutine is waiting on some other condition: namely, a # delegated coroutine or a joined coroutine. In this case, the # coroutine should *also* appear as a value in one of the below # dictionaries `delegators` or `joiners`. threads = {root_coro: ValueEvent(None)} # Maps child coroutines to delegating parents. delegators = {} # Maps child coroutines to joining (exit-waiting) parents. joiners = collections.defaultdict(list) def complete_thread(coro, return_value): """Remove a coroutine from the scheduling pool, awaking delegators and joiners as necessary and returning the specified value to any delegating parent. """ del threads[coro] # Resume delegator. if coro in delegators: threads[delegators[coro]] = ValueEvent(return_value) del delegators[coro] # Resume joiners. if coro in joiners: for parent in joiners[coro]: threads[parent] = ValueEvent(None) del joiners[coro] def advance_thread(coro, value, is_exc=False): """After an event is fired, run a given coroutine associated with it in the threads dict until it yields again. If the coroutine exits, then the thread is removed from the pool. If the coroutine raises an exception, it is reraised in a ThreadError. If is_exc is True, then the value must be an exc_info tuple and the exception is thrown into the coroutine. """ try: if is_exc: next_event = coro.throw(*value) else: next_event = coro.send(value) except StopIteration: # Thread is done. complete_thread(coro, None) except BaseException: # Thread raised some other exception. del threads[coro] raise ThreadError(coro, sys.exc_info()) else: if isinstance(next_event, types.GeneratorType): # Automatically invoke sub-coroutines. (Shorthand for # explicit bluelet.call().) next_event = DelegationEvent(next_event) threads[coro] = next_event def kill_thread(coro): """Unschedule this thread and its (recursive) delegates.""" # Collect all coroutines in the delegation stack. coros = [coro] while isinstance(threads[coro], Delegated): coro = threads[coro].child coros.append(coro) # Complete each coroutine from the top to the bottom of the # stack. for coro in reversed(coros): complete_thread(coro, None) # Continue advancing threads until root thread exits. exit_te = None while threads: try: # Look for events that can be run immediately. Continue # running immediate events until nothing is ready. while True: have_ready = False for coro, event in list(threads.items()): if isinstance(event, SpawnEvent): threads[event.spawned] = ValueEvent(None) # Spawn. advance_thread(coro, None) have_ready = True elif isinstance(event, ValueEvent): advance_thread(coro, event.value) have_ready = True elif isinstance(event, ExceptionEvent): advance_thread(coro, event.exc_info, True) have_ready = True elif isinstance(event, DelegationEvent): threads[coro] = Delegated(event.spawned) # Suspend. threads[event.spawned] = ValueEvent(None) # Spawn. delegators[event.spawned] = coro have_ready = True elif isinstance(event, ReturnEvent): # Thread is done. complete_thread(coro, event.value) have_ready = True elif isinstance(event, JoinEvent): threads[coro] = SUSPENDED # Suspend. joiners[event.child].append(coro) have_ready = True elif isinstance(event, KillEvent): threads[coro] = ValueEvent(None) kill_thread(event.child) have_ready = True # Only start the select when nothing else is ready. if not have_ready: break # Wait and fire. event2coro = {v: k for k, v in threads.items()} for event in _event_select(threads.values()): # Run the IO operation, but catch socket errors. try: value = event.fire() except OSError as exc: if ( isinstance(exc.args, tuple) and exc.args[0] == errno.EPIPE ): # Broken pipe. Remote host disconnected. pass elif ( isinstance(exc.args, tuple) and exc.args[0] == errno.ECONNRESET ): # Connection was reset by peer. pass else: traceback.print_exc() # Abort the coroutine. threads[event2coro[event]] = ReturnEvent(None) else: advance_thread(event2coro[event], value) except ThreadError as te: # Exception raised from inside a thread. event = ExceptionEvent(te.exc_info) if te.coro in delegators: # The thread is a delegate. Raise exception in its # delegator. threads[delegators[te.coro]] = event del delegators[te.coro] else: # The thread is root-level. Raise in client code. exit_te = te break except BaseException: # For instance, KeyboardInterrupt during select(). Raise # into root thread and terminate others. threads = {root_coro: ExceptionEvent(sys.exc_info())} # If any threads still remain, kill them. for coro in threads: coro.close() # If we're exiting with an exception, raise it in the client. if exit_te: exit_te.reraise() # Sockets and their associated events. class SocketClosedError(Exception): pass class Listener: """A socket wrapper object for listening sockets.""" def __init__(self, host, port): """Create a listening socket on the given hostname and port.""" self._closed = False self.host = host self.port = port self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind((host, port)) self.sock.listen(5) def accept(self): """An event that waits for a connection on the listening socket. When a connection is made, the event returns a Connection object. """ if self._closed: raise SocketClosedError() return AcceptEvent(self) def close(self): """Immediately close the listening socket. (Not an event.)""" self._closed = True self.sock.close() class Connection: """A socket wrapper object for connected sockets.""" def __init__(self, sock, addr): self.sock = sock self.addr = addr self._buf = b"" self._closed = False def close(self): """Close the connection.""" self._closed = True self.sock.close() def recv(self, size): """Read at most size bytes of data from the socket.""" if self._closed: raise SocketClosedError() if self._buf: # We already have data read previously. out = self._buf[:size] self._buf = self._buf[size:] return ValueEvent(out) else: return ReceiveEvent(self, size) def send(self, data): """Sends data on the socket, returning the number of bytes successfully sent. """ if self._closed: raise SocketClosedError() return SendEvent(self, data) def sendall(self, data): """Send all of data on the socket.""" if self._closed: raise SocketClosedError() return SendEvent(self, data, True) def readline(self, terminator=b"\n", bufsize=1024): """Reads a line (delimited by terminator) from the socket.""" if self._closed: raise SocketClosedError() while True: if terminator in self._buf: line, self._buf = self._buf.split(terminator, 1) line += terminator yield ReturnEvent(line) break data = yield ReceiveEvent(self, bufsize) if data: self._buf += data else: line = self._buf self._buf = b"" yield ReturnEvent(line) break class AcceptEvent(WaitableEvent): """An event for Listener objects (listening sockets) that suspends execution until the socket gets a connection. """ def __init__(self, listener): self.listener = listener def waitables(self): return (self.listener.sock,), (), () def fire(self): sock, addr = self.listener.sock.accept() return Connection(sock, addr) class ReceiveEvent(WaitableEvent): """An event for Connection objects (connected sockets) for asynchronously reading data. """ def __init__(self, conn, bufsize): self.conn = conn self.bufsize = bufsize def waitables(self): return (self.conn.sock,), (), () def fire(self): return self.conn.sock.recv(self.bufsize) class SendEvent(WaitableEvent): """An event for Connection objects (connected sockets) for asynchronously writing data. """ def __init__(self, conn, data, sendall=False): self.conn = conn self.data = data self.sendall = sendall def waitables(self): return (), (self.conn.sock,), () def fire(self): if self.sendall: return self.conn.sock.sendall(self.data) else: return self.conn.sock.send(self.data) # Public interface for threads; each returns an event object that # can immediately be "yield"ed. def null(): """Event: yield to the scheduler without doing anything special.""" return ValueEvent(None) def spawn(coro): """Event: add another coroutine to the scheduler. Both the parent and child coroutines run concurrently. """ if not isinstance(coro, types.GeneratorType): raise ValueError(f"{coro} is not a coroutine") return SpawnEvent(coro) def call(coro): """Event: delegate to another coroutine. The current coroutine is resumed once the sub-coroutine finishes. If the sub-coroutine returns a value using end(), then this event returns that value. """ if not isinstance(coro, types.GeneratorType): raise ValueError(f"{coro} is not a coroutine") return DelegationEvent(coro) def end(value=None): """Event: ends the coroutine and returns a value to its delegator. """ return ReturnEvent(value) def read(fd, bufsize=None): """Event: read from a file descriptor asynchronously.""" if bufsize is None: # Read all. def reader(): buf = [] while True: data = yield read(fd, 1024) if not data: break buf.append(data) yield ReturnEvent("".join(buf)) return DelegationEvent(reader()) else: return ReadEvent(fd, bufsize) def write(fd, data): """Event: write to a file descriptor asynchronously.""" return WriteEvent(fd, data) def connect(host, port): """Event: connect to a network address and return a Connection object for communicating on the socket. """ addr = (host, port) sock = socket.create_connection(addr) return ValueEvent(Connection(sock, addr)) def sleep(duration): """Event: suspend the thread for ``duration`` seconds.""" return SleepEvent(duration) def join(coro): """Suspend the thread until another, previously `spawn`ed thread completes. """ return JoinEvent(coro) def kill(coro): """Halt the execution of a different `spawn`ed thread.""" return KillEvent(coro) # Convenience function for running socket servers. def server(host, port, func): """A coroutine that runs a network server. Host and port specify the listening address. func should be a coroutine that takes a single parameter, a Connection object. The coroutine is invoked for every incoming connection on the listening socket. """ def handler(conn): try: yield func(conn) finally: conn.close() listener = Listener(host, port) try: while True: conn = yield listener.accept() yield spawn(handler(conn)) except KeyboardInterrupt: pass finally: listener.close() ================================================ FILE: beets/util/color.py ================================================ from __future__ import annotations import os import re from functools import cache from typing import TYPE_CHECKING, Literal import confuse from beets import config if TYPE_CHECKING: from beets.autotag.distance import Distance # ANSI terminal colorization code heavily inspired by pygments: # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) COLOR_ESCAPE = "\x1b" LEGACY_COLORS = { "black": ["black"], "darkred": ["red"], "darkgreen": ["green"], "brown": ["yellow"], "darkyellow": ["yellow"], "darkblue": ["blue"], "purple": ["magenta"], "darkmagenta": ["magenta"], "teal": ["cyan"], "darkcyan": ["cyan"], "lightgray": ["white"], "darkgray": ["bold", "black"], "red": ["bold", "red"], "green": ["bold", "green"], "yellow": ["bold", "yellow"], "blue": ["bold", "blue"], "fuchsia": ["bold", "magenta"], "magenta": ["bold", "magenta"], "turquoise": ["bold", "cyan"], "cyan": ["bold", "cyan"], "white": ["bold", "white"], } # All ANSI Colors. CODE_BY_COLOR = { # Styles. "normal": 0, "bold": 1, "faint": 2, "italic": 3, "underline": 4, "blink_slow": 5, "blink_rapid": 6, "inverse": 7, "conceal": 8, "crossed_out": 9, # Text colors. "black": 30, "red": 31, "green": 32, "yellow": 33, "blue": 34, "magenta": 35, "cyan": 36, "white": 37, "bright_black": 90, "bright_red": 91, "bright_green": 92, "bright_yellow": 93, "bright_blue": 94, "bright_magenta": 95, "bright_cyan": 96, "bright_white": 97, # Background colors. "bg_black": 40, "bg_red": 41, "bg_green": 42, "bg_yellow": 43, "bg_blue": 44, "bg_magenta": 45, "bg_cyan": 46, "bg_white": 47, "bg_bright_black": 100, "bg_bright_red": 101, "bg_bright_green": 102, "bg_bright_yellow": 103, "bg_bright_blue": 104, "bg_bright_magenta": 105, "bg_bright_cyan": 106, "bg_bright_white": 107, } RESET_COLOR = f"{COLOR_ESCAPE}[39;49;00m" # Precompile common ANSI-escape regex patterns ANSI_CODE_REGEX = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") ESC_TEXT_REGEX = re.compile( rf"""(?P[^{COLOR_ESCAPE}]*) (?P(?:{ANSI_CODE_REGEX.pattern})+) (?P[^{COLOR_ESCAPE}]+)(?P{re.escape(RESET_COLOR)}) (?P[^{COLOR_ESCAPE}]*)""", re.VERBOSE, ) ColorName = Literal[ "text_success", "text_warning", "text_error", "text_highlight", "text_highlight_minor", "action_default", "action", # New Colors "text_faint", "import_path", "import_path_items", "action_description", "changed", "text_diff_added", "text_diff_removed", ] @cache def get_color_config() -> dict[ColorName, str]: """Parse and validate color configuration, converting names to ANSI codes. Processes the UI color configuration, handling both new list format and legacy single-color format. Validates all color names against known codes and raises an error for any invalid entries. """ template_dict: dict[ColorName, confuse.OneOf[str | list[str]]] = { n: confuse.OneOf( [ confuse.Choice(sorted(LEGACY_COLORS)), confuse.Sequence(confuse.Choice(sorted(CODE_BY_COLOR))), ] ) for n in ColorName.__args__ # type: ignore[attr-defined] } template = confuse.MappingTemplate(template_dict) colors_by_color_name = { k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) for k, v in config["ui"]["colors"].get(template).items() } return { n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) for n, colors in colors_by_color_name.items() } def _colorize(color_name: ColorName, text: str) -> str: """Apply ANSI color formatting to text based on configuration settings.""" color_code = get_color_config()[color_name] return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" def colorize(color_name: ColorName, text: str) -> str: """Colorize text when color output is enabled.""" if config["ui"]["color"] and "NO_COLOR" not in os.environ: return _colorize(color_name, text) return text def dist_colorize(string: str, dist: Distance) -> str: """Formats a string as a colorized similarity string according to a distance. """ if dist <= config["match"]["strong_rec_thresh"].as_number(): string = colorize("text_success", string) elif dist <= config["match"]["medium_rec_thresh"].as_number(): string = colorize("text_warning", string) else: string = colorize("text_error", string) return string def uncolorize(colored_text: str) -> str: """Remove colors from a string.""" # Define a regular expression to match ANSI codes. # See: http://stackoverflow.com/a/2187024/1382707 # Explanation of regular expression: # \x1b - matches ESC character # \[ - matches opening square bracket # [;\d]* - matches a sequence consisting of one or more digits or # semicola # [A-Za-z] - matches a letter return ANSI_CODE_REGEX.sub("", colored_text) def color_split(colored_text: str, index: int) -> tuple[str, str]: length = 0 pre_split = "" post_split = "" found_color_code = None found_split = False for part in ANSI_CODE_REGEX.split(colored_text): # Count how many real letters we have passed length += color_len(part) if found_split: post_split += part else: if ANSI_CODE_REGEX.match(part): # This is a color code if part == RESET_COLOR: found_color_code = None else: found_color_code = part pre_split += part else: if index < length: # Found part with our split in. split_index = index - (length - color_len(part)) found_split = True if found_color_code: pre_split += f"{part[:split_index]}{RESET_COLOR}" post_split += f"{found_color_code}{part[split_index:]}" else: pre_split += part[:split_index] post_split += part[split_index:] else: # Not found, add this part to the pre split pre_split += part return pre_split, post_split def color_len(colored_text: str) -> int: """Measure the length of a string while excluding ANSI codes from the measurement. The standard `len(my_string)` method also counts ANSI codes to the string length, which is counterproductive when layouting a Terminal interface. """ # Return the length of the uncolored string. return len(uncolorize(colored_text)) ================================================ FILE: beets/util/config.py ================================================ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Collection, Sequence def sanitize_choices( choices: Sequence[str], choices_all: Collection[str] ) -> list[str]: """Clean up a stringlist configuration attribute: keep only choices elements present in choices_all, remove duplicate elements, expand '*' wildcard while keeping original stringlist order. """ seen: set[str] = set() others = [x for x in choices_all if x not in choices] res: list[str] = [] for s in choices: if s not in seen: if s in list(choices_all): res.append(s) elif s == "*": res.extend(others) seen.add(s) return res def sanitize_pairs( pairs: Sequence[tuple[str, str]], pairs_all: Sequence[tuple[str, str]] ) -> list[tuple[str, str]]: """Clean up a single-element mapping configuration attribute as returned by Confuse's `Pairs` template: keep only two-element tuples present in pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*') wildcards while keeping the original order. Note that ('*', '*') and ('*', 'whatever') have the same effect. For example, >>> sanitize_pairs( ... [('foo', 'baz bar'), ('key', '*'), ('*', '*')], ... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'), ... ('key', 'value')] ... ) [('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')] """ pairs_all = list(pairs_all) seen: set[tuple[str, str]] = set() others = [x for x in pairs_all if x not in pairs] res: list[tuple[str, str]] = [] for k, values in pairs: for v in values.split(): x = (k, v) if x in pairs_all: if x not in seen: seen.add(x) res.append(x) elif k == "*": new = [o for o in others if o not in seen] seen.update(new) res.extend(new) elif v == "*": new = [o for o in others if o not in seen and o[0] == k] seen.update(new) res.extend(new) return res ================================================ FILE: beets/util/deprecation.py ================================================ from __future__ import annotations import warnings from importlib import import_module from typing import TYPE_CHECKING, Any from packaging.version import Version import beets if TYPE_CHECKING: from logging import Logger def _format_message(old: str, new: str | None = None) -> str: next_major = f"{Version(beets.__version__).major + 1}.0.0" msg = f"{old} is deprecated and will be removed in version {next_major}." if new: msg += f" Use {new} instead." return msg def deprecate_for_user( logger: Logger, old: str, new: str | None = None ) -> None: logger.warning(_format_message(old, new)) def deprecate_for_maintainers( old: str, new: str | None = None, stacklevel: int = 1 ) -> None: """Issue a deprecation warning visible to maintainers during development. Emits a DeprecationWarning that alerts developers about deprecated code patterns. Unlike user-facing warnings, these are primarily for internal code maintenance and appear during test runs or with warnings enabled. """ warnings.warn( _format_message(old, new), DeprecationWarning, stacklevel=stacklevel + 1 ) def deprecate_imports( old_module: str, new_module_by_name: dict[str, str], name: str ) -> Any: """Handle deprecated module imports by redirecting to new locations. Facilitates gradual migration of module structure by intercepting import attempts for relocated functionality. Issues deprecation warnings while transparently providing access to the moved implementation, allowing existing code to continue working during transition periods. """ if new_module := new_module_by_name.get(name): deprecate_for_maintainers( f"'{old_module}.{name}'", f"'{new_module}.{name}'", stacklevel=2 ) return getattr(import_module(new_module), name) raise AttributeError(f"module '{old_module}' has no attribute '{name}'") ================================================ FILE: beets/util/diff.py ================================================ from __future__ import annotations from difflib import SequenceMatcher from typing import TYPE_CHECKING from .color import colorize if TYPE_CHECKING: from collections.abc import Iterable from beets.dbcore.db import FormattedMapping from beets.library.models import LibModel def colordiff(a: str, b: str) -> tuple[str, str]: """Intelligently highlight the differences between two strings.""" before = "" after = "" matcher = SequenceMatcher(lambda _: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): before_part, after_part = a[a_start:a_end], b[b_start:b_end] if op in {"delete", "replace"}: before_part = colorize("text_diff_removed", before_part) if op in {"insert", "replace"}: after_part = colorize("text_diff_added", after_part) before += before_part after += after_part return before, after FLOAT_EPSILON = 0.01 def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str: added = newset - oldset removed = oldset - newset parts = [ f"{field}:", *(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)), *(colorize("text_diff_added", f" + {i}") for i in sorted(added)), ] return "\n".join(parts) def _field_diff( field: str, old: FormattedMapping, new: FormattedMapping ) -> str | None: """Given two Model objects and their formatted views, format their values for `field` and highlight changes among them. Return a human-readable string. If the value has not changed, return None instead. """ # If no change, abort. if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or ( isinstance(oldval, float) and isinstance(newval, float) and abs(oldval - newval) < FLOAT_EPSILON ): return None if isinstance(oldval, list): if (oldset := set(oldval)) != (newset := set(newval)): return _multi_value_diff(field, oldset, newset) return None # Get formatted values for output. oldstr, newstr = old.get(field, ""), new.get(field, "") if field not in new: return colorize("text_diff_removed", f"{field}: {oldstr}") if field not in old: return colorize("text_diff_added", f"{field}: {newstr}") # For strings, highlight changes. For others, colorize the whole thing. if isinstance(oldval, str): oldstr, newstr = colordiff(oldstr, newstr) else: oldstr = colorize("text_diff_removed", oldstr) newstr = colorize("text_diff_added", newstr) return f"{field}: {oldstr} -> {newstr}" def get_model_changes( new: LibModel, old: LibModel, fields: Iterable[str] | None, ) -> list[str]: """Compute human-readable diff lines for changed fields between two models. Compares ``old`` and ``new`` across fixed and flex fields, excluding internal ones like ``mtime``. If ``fields`` is provided, only the specified subset is considered. """ # Keep the formatted views around instead of re-creating them in each # iteration step old_fmt = old.formatted() new_fmt = new.formatted() # Build up lines showing changed fields. diff_fields = (set(old) | set(new)) - {"mtime"} if allowed_fields := set(fields or {}): diff_fields &= allowed_fields return [ d for f in sorted(diff_fields) if (d := _field_diff(f, old_fmt, new_fmt)) ] ================================================ FILE: beets/util/functemplate.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module implements a string formatter based on the standard PEP 292 string.Template class extended with function calls. Variables, as with string.Template, are indicated with $ and functions are delimited with %. This module assumes that everything is Unicode: the template and the substitution values. Bytestrings are not supported. Also, the templates always behave like the ``safe_substitute`` method in the standard library: unknown symbols are left intact. This is sort of like a tiny, horrible degeneration of a real templating engine like Jinja2 or Mustache. """ import ast import dis import functools import re import types SYMBOL_DELIM = "$" FUNC_DELIM = "%" GROUP_OPEN = "{" GROUP_CLOSE = "}" ARG_SEP = "," ESCAPE_CHAR = "$" VARIABLE_PREFIX = "__var_" FUNCTION_PREFIX = "__func_" class Environment: """Contains the values and functions to be substituted into a template. """ def __init__(self, values, functions): self.values = values self.functions = functions # Code generation helpers. def ex_rvalue(name): """A variable store expression.""" return ast.Name(name, ast.Load()) def ex_literal(val): """An int, float, long, bool, string, or None literal with the given value. """ return ast.Constant(val) def ex_call(func, args): """A function-call expression with only positional parameters. The function may be an expression or the name of a function. Each argument may be an expression or a value to be used as a literal. """ if isinstance(func, str): func = ex_rvalue(func) args = list(args) for i in range(len(args)): if not isinstance(args[i], ast.expr): args[i] = ex_literal(args[i]) return ast.Call(func, args, []) def compile_func(arg_names, statements, name="_the_func", debug=False): """Compile a list of statements as the body of a function and return the resulting Python function. If `debug`, then print out the bytecode of the compiled function. """ args_fields = { "args": [ast.arg(arg=n, annotation=None) for n in arg_names], "kwonlyargs": [], "kw_defaults": [], "defaults": [ex_literal(None) for _ in arg_names], } args_fields["posonlyargs"] = [] args = ast.arguments(**args_fields) func_def = ast.FunctionDef( name=name, args=args, body=statements, decorator_list=[], ) mod = ast.Module([func_def], []) ast.fix_missing_locations(mod) prog = compile(mod, "", "exec") # Debug: show bytecode. if debug: dis.dis(prog) for const in prog.co_consts: if isinstance(const, types.CodeType): dis.dis(const) the_locals = {} exec(prog, {}, the_locals) return the_locals[name] # AST nodes for the template language. class Symbol: """A variable-substitution symbol in a template.""" def __init__(self, ident, original): self.ident = ident self.original = original def __repr__(self): return f"Symbol({self.ident!r})" def evaluate(self, env): """Evaluate the symbol in the environment, returning a Unicode string. """ if self.ident in env.values: # Substitute for a value. return env.values[self.ident] else: # Keep original text. return self.original def translate(self): """Compile the variable lookup.""" ident = self.ident expr = ex_rvalue(f"{VARIABLE_PREFIX}{ident}") return [expr], {ident}, set() class Call: """A function call in a template.""" def __init__(self, ident, args, original): self.ident = ident self.args = args self.original = original def __repr__(self): return f"Call({self.ident!r}, {self.args!r}, {self.original!r})" def evaluate(self, env): """Evaluate the function call in the environment, returning a Unicode string. """ if self.ident in env.functions: arg_vals = [expr.evaluate(env) for expr in self.args] try: out = env.functions[self.ident](*arg_vals) except Exception as exc: # Function raised exception! Maybe inlining the name of # the exception will help debug. return f"<{exc}>" return str(out) else: return self.original def translate(self): """Compile the function call.""" varnames = set() funcnames = {self.ident} arg_exprs = [] for arg in self.args: subexprs, subvars, subfuncs = arg.translate() varnames.update(subvars) funcnames.update(subfuncs) # Create a subexpression that joins the result components of # the arguments. arg_exprs.append( ex_call( ast.Attribute(ex_literal(""), "join", ast.Load()), [ ex_call( "map", [ ex_rvalue(str.__name__), ast.List(subexprs, ast.Load()), ], ) ], ) ) subexpr_call = ex_call(f"{FUNCTION_PREFIX}{self.ident}", arg_exprs) return [subexpr_call], varnames, funcnames class Expression: """Top-level template construct: contains a list of text blobs, Symbols, and Calls. """ def __init__(self, parts): self.parts = parts def __repr__(self): return f"Expression({self.parts!r})" def evaluate(self, env): """Evaluate the entire expression in the environment, returning a Unicode string. """ out = [] for part in self.parts: if isinstance(part, str): out.append(part) else: out.append(part.evaluate(env)) return "".join(map(str, out)) def translate(self): """Compile the expression to a list of Python AST expressions, a set of variable names used, and a set of function names. """ expressions = [] varnames = set() funcnames = set() for part in self.parts: if isinstance(part, str): expressions.append(ex_literal(part)) else: e, v, f = part.translate() expressions.extend(e) varnames.update(v) funcnames.update(f) return expressions, varnames, funcnames # Parser. class ParseError(Exception): pass class Parser: """Parses a template expression string. Instantiate the class with the template source and call ``parse_expression``. The ``pos`` field will indicate the character after the expression finished and ``parts`` will contain a list of Unicode strings, Symbols, and Calls reflecting the concatenated portions of the expression. This is a terrible, ad-hoc parser implementation based on a left-to-right scan with no lexing step to speak of; it's probably both inefficient and incorrect. Maybe this should eventually be replaced with a real, accepted parsing technique (PEG, parser generator, etc.). """ def __init__(self, string, in_argument=False): """Create a new parser. :param in_arguments: boolean that indicates the parser is to be used for parsing function arguments, ie. considering commas (`ARG_SEP`) a special character """ self.string = string self.in_argument = in_argument self.pos = 0 self.parts = [] # Common parsing resources. special_chars = ( SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE, ESCAPE_CHAR, ) escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) terminator_chars = (GROUP_CLOSE,) def parse_expression(self): """Parse a template expression starting at ``pos``. Resulting components (Unicode strings, Symbols, and Calls) are added to the ``parts`` field, a list. The ``pos`` field is updated to be the next character after the expression. """ # Append comma (ARG_SEP) to the list of special characters only when # parsing function arguments. extra_special_chars = (ARG_SEP,) if self.in_argument else () special_chars = (*self.special_chars, *extra_special_chars) special_char_re = re.compile( rf"[{''.join(map(re.escape, special_chars))}]|\Z" ) text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] if char not in special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( special_char_re.search(self.string[self.pos :]).start() + self.pos ) text_parts.append(self.string[self.pos : next_pos]) self.pos = next_pos continue if self.pos == len(self.string) - 1: # The last character can never begin a structure, so we # just interpret it as a literal character (unless it # terminates the expression, as with , and }). if char not in self.terminator_chars + extra_special_chars: text_parts.append(char) self.pos += 1 break next_char = self.string[self.pos + 1] if char == ESCAPE_CHAR and next_char in ( self.escapable_chars + extra_special_chars ): # An escaped special character ($$, $}, etc.). Note that # ${ is not an escape sequence: this is ambiguous with # the start of a symbol and it's not necessary (just # using { suffices in all cases). text_parts.append(next_char) self.pos += 2 # Skip the next character. continue # Shift all characters collected so far into a single string. if text_parts: self.parts.append("".join(text_parts)) text_parts = [] if char == SYMBOL_DELIM: # Parse a symbol. self.parse_symbol() elif char == FUNC_DELIM: # Parse a function call. self.parse_call() elif char in self.terminator_chars + extra_special_chars: # Template terminated. break elif char == GROUP_OPEN: # Start of a group has no meaning hear; just pass # through the character. text_parts.append(char) self.pos += 1 else: assert False # If any parsed characters remain, shift them into a string. if text_parts: self.parts.append("".join(text_parts)) def parse_symbol(self): """Parse a variable reference (like ``$foo`` or ``${foo}``) starting at ``pos``. Possibly appends a Symbol object (or, failing that, text) to the ``parts`` field and updates ``pos``. The character at ``pos`` must, as a precondition, be ``$``. """ assert self.pos < len(self.string) assert self.string[self.pos] == SYMBOL_DELIM if self.pos == len(self.string) - 1: # Last character. self.parts.append(SYMBOL_DELIM) self.pos += 1 return next_char = self.string[self.pos + 1] start_pos = self.pos self.pos += 1 if next_char == GROUP_OPEN: # A symbol like ${this}. self.pos += 1 # Skip opening. closer = self.string.find(GROUP_CLOSE, self.pos) if closer == -1 or closer == self.pos: # No closing brace found or identifier is empty. self.parts.append(self.string[start_pos : self.pos]) else: # Closer found. ident = self.string[self.pos : closer] self.pos = closer + 1 self.parts.append( Symbol(ident, self.string[start_pos : self.pos]) ) else: # A bare-word symbol. ident = self._parse_ident() if ident: # Found a real symbol. self.parts.append( Symbol(ident, self.string[start_pos : self.pos]) ) else: # A standalone $. self.parts.append(SYMBOL_DELIM) def parse_call(self): """Parse a function call (like ``%foo{bar,baz}``) starting at ``pos``. Possibly appends a Call object to ``parts`` and update ``pos``. The character at ``pos`` must be ``%``. """ assert self.pos < len(self.string) assert self.string[self.pos] == FUNC_DELIM start_pos = self.pos self.pos += 1 ident = self._parse_ident() if not ident: # No function name. self.parts.append(FUNC_DELIM) return if self.pos >= len(self.string): # Identifier terminates string. self.parts.append(self.string[start_pos : self.pos]) return if self.string[self.pos] != GROUP_OPEN: # Argument list not opened. self.parts.append(self.string[start_pos : self.pos]) return # Skip past opening brace and try to parse an argument list. self.pos += 1 args = self.parse_argument_list() if self.pos >= len(self.string) or self.string[self.pos] != GROUP_CLOSE: # Arguments unclosed. self.parts.append(self.string[start_pos : self.pos]) return self.pos += 1 # Move past closing brace. self.parts.append(Call(ident, args, self.string[start_pos : self.pos])) def parse_argument_list(self): """Parse a list of arguments starting at ``pos``, returning a list of Expression objects. Does not modify ``parts``. Should leave ``pos`` pointing to a } character or the end of the string. """ # Try to parse a subexpression in a subparser. expressions = [] while self.pos < len(self.string): subparser = Parser(self.string[self.pos :], in_argument=True) subparser.parse_expression() # Extract and advance past the parsed expression. expressions.append(Expression(subparser.parts)) self.pos += subparser.pos if ( self.pos >= len(self.string) or self.string[self.pos] == GROUP_CLOSE ): # Argument list terminated by EOF or closing brace. break # Only other way to terminate an expression is with ,. # Continue to the next argument. assert self.string[self.pos] == ARG_SEP self.pos += 1 return expressions def _parse_ident(self): """Parse an identifier and return it (possibly an empty string). Updates ``pos``. """ remainder = self.string[self.pos :] ident = re.match(r"\w*", remainder).group(0) self.pos += len(ident) return ident def _parse(template): """Parse a top-level template string Expression. Any extraneous text is considered literal text. """ parser = Parser(template) parser.parse_expression() parts = parser.parts remainder = parser.string[parser.pos :] if remainder: parts.append(remainder) return Expression(parts) @functools.lru_cache(maxsize=128) def template(fmt): return Template(fmt) # External interface. class Template: """A string template, including text, Symbols, and Calls.""" def __init__(self, template): self.expr = _parse(template) self.original = template self.compiled = self.translate() def __eq__(self, other): return self.original == other.original def interpret(self, values={}, functions={}): """Like `substitute`, but forces the interpreter (rather than the compiled version) to be used. The interpreter includes exception-handling code for missing variables and buggy template functions but is much slower. """ return self.expr.evaluate(Environment(values, functions)) def substitute(self, values={}, functions={}): """Evaluate the template given the values and functions.""" try: res = self.compiled(values, functions) except Exception: # Handle any exceptions thrown by compiled version. res = self.interpret(values, functions) return res def translate(self): """Compile the template to a Python function.""" expressions, varnames, funcnames = self.expr.translate() argnames = [] for varname in varnames: argnames.append(f"{VARIABLE_PREFIX}{varname}") for funcname in funcnames: argnames.append(f"{FUNCTION_PREFIX}{funcname}") func = compile_func( argnames, [ast.Return(ast.List(expressions, ast.Load()))], ) def wrapper_func(values={}, functions={}): args = {} for varname in varnames: args[f"{VARIABLE_PREFIX}{varname}"] = values[varname] for funcname in funcnames: args[f"{FUNCTION_PREFIX}{funcname}"] = functions[funcname] parts = func(**args) return "".join(parts) return wrapper_func # Performance tests. if __name__ == "__main__": import timeit _tmpl = Template("foo $bar %baz{foozle $bar barzle} $bar") _vars = {"bar": "qux"} _funcs = {"baz": str.upper} interp_time = timeit.timeit( "_tmpl.interpret(_vars, _funcs)", "from __main__ import _tmpl, _vars, _funcs", number=10000, ) print(interp_time) comp_time = timeit.timeit( "_tmpl.substitute(_vars, _funcs)", "from __main__ import _tmpl, _vars, _funcs", number=10000, ) print(comp_time) print("Speedup:", interp_time / comp_time) ================================================ FILE: beets/util/hidden.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # Copyright 2024, Arav K. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Simple library to work out if a file is hidden on different platforms.""" import ctypes import os import stat import sys from pathlib import Path def is_hidden(path: bytes | Path) -> bool: """ Determine whether the given path is treated as a 'hidden file' by the OS. """ if isinstance(path, bytes): path = Path(os.fsdecode(path)) # TODO: Avoid doing a platform check on every invocation of the function. # TODO: Stop supporting 'bytes' inputs once 'pathlib' is fully integrated. if sys.platform == "win32": # On Windows, we check for an FS-provided attribute. # FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation. hidden_mask = 2 # Retrieve the attributes for the file. attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # Ensure the attribute mask is valid. if attrs < 0: return False # Check for the hidden attribute. return attrs & hidden_mask # On OS X, we check for an FS-provided attribute. if sys.platform == "darwin": if hasattr(os.stat_result, "st_flags") and hasattr(stat, "UF_HIDDEN"): if path.lstat().st_flags & stat.UF_HIDDEN: return True # On all non-Windows platforms, we check for a '.'-prefixed file name. if path.name.startswith("."): return True return False ================================================ FILE: beets/util/id_extractors.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Helpers around the extraction of album/track ID's from metadata sources.""" from __future__ import annotations import re from beets import logging log = logging.getLogger("beets") PATTERN_BY_SOURCE = { "spotify": re.compile(r"(?:^|open\.spotify\.com/[^/]+/)([0-9A-Za-z]{22})"), "deezer": re.compile(r"(?:^|deezer\.com/)(?:[a-z]*/)?(?:[^/]+/)?(\d+)"), "beatport": re.compile(r"(?:^|beatport\.com/release/.+/)(\d+)$"), "musicbrainz": re.compile(r"(\w{8}(?:-\w{4}){3}-\w{12})"), # - plain integer, optionally wrapped in brackets and prefixed by an # 'r', as this is how discogs displays the release ID on its webpage. # - legacy url format: discogs.com//release/ # - legacy url short format: discogs.com/release/ # - current url format: discogs.com/release/- # See #291, #4080 and #4085 for the discussions leading up to these # patterns. "discogs": re.compile( r"(?:^|\[?r|discogs\.com/(?:[^/]+/)?release/)(\d+)\b" ), # There is no such thing as a Bandcamp album or artist ID, the URL can be # used as the identifier. The Bandcamp metadata source plugin works that way # - https://github.com/snejus/beetcamp. Bandcamp album URLs usually look # like: https://nameofartist.bandcamp.com/album/nameofalbum "bandcamp": re.compile(r"(.+)"), "tidal": re.compile(r"([^/]+)$"), } def extract_release_id(source: str, id_: str) -> str | None: """Extract the release ID from a given source and ID. Normally, the `id_` is a url string which contains the ID of the release. This function extracts the ID from the URL based on the `source` provided. """ try: source_pattern = PATTERN_BY_SOURCE[source.lower()] except KeyError: log.debug( "Unknown source '{}' for ID extraction. Returning id/url as-is.", source, ) return id_ if m := source_pattern.search(str(id_)): return m[1] return None ================================================ FILE: beets/util/layout.py ================================================ from __future__ import annotations from typing import TYPE_CHECKING, NamedTuple import beets from .color import ( ESC_TEXT_REGEX, RESET_COLOR, color_len, color_split, uncolorize, ) if TYPE_CHECKING: from collections.abc import Callable, Iterator class Side(NamedTuple): """A labeled segment of a two-column layout row with optional fixed width. Holds prefix, content, and suffix strings that together form one side of a formatted row. Width measurements account for ANSI color codes, which do not contribute to visible character count. """ prefix: str contents: str suffix: str width: int = -1 @property def rendered(self) -> str: """Assemble the full display string by joining prefix, contents, and suffix.""" return f"{self.prefix}{self.contents}{self.suffix}" @property def prefix_width(self) -> int: """Visible character width of the prefix, excluding color codes.""" return color_len(self.prefix) @property def suffix_width(self) -> int: """Visible character width of the suffix, excluding color codes.""" return color_len(self.suffix) @property def rendered_width(self) -> int: """Visible character width of the fully assembled string.""" return color_len(self.rendered) def indent(count: int) -> str: """Returns a string with `count` many spaces.""" return " " * count def split_into_lines(string: str, first_width: int, width: int) -> list[str]: """Split string into a list of substrings at whitespace. The first substring has a length not longer than `first_width`, and the rest of substrings have a length not longer than `width`. `string` may contain ANSI codes at word borders. """ words = [] if uncolorize(string) == string: # No colors in string words = string.split() else: # Use a regex to find escapes and the text within them. for m in ESC_TEXT_REGEX.finditer(string): # m contains four groups: # pretext - any text before escape sequence # esc - intitial escape sequence # text - text, no escape sequence, may contain spaces # reset - ASCII colour reset space_before_text = False if m.group("pretext") != "": # Some pretext found, let's handle it # Add any words in the pretext words += m.group("pretext").split() if m.group("pretext")[-1] == " ": # Pretext ended on a space space_before_text = True else: # Pretext ended mid-word, ensure next word pass else: # pretext empty, treat as if there is a space before space_before_text = True if m.group("text")[0] == " ": # First character of the text is a space space_before_text = True # Now, handle the words in the main text: raw_words = m.group("text").split() if space_before_text: # Colorize each word with pre/post escapes # Reconstruct colored words words += [ f"{m['esc']}{raw_word}{RESET_COLOR}" for raw_word in raw_words ] elif raw_words: # Pretext stops mid-word if m.group("esc") != RESET_COLOR: # Add the rest of the current word, with a reset after it words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" # Add the subsequent colored words: words += [ f"{m['esc']}{raw_word}{RESET_COLOR}" for raw_word in raw_words[1:] ] else: # Caught a mid-word escape sequence words[-1] += raw_words[0] words += raw_words[1:] if ( m.group("text")[-1] != " " and m.group("posttext") != "" and m.group("posttext")[0] != " " ): # reset falls mid-word post_text = m.group("posttext").split() words[-1] += post_text[0] words += post_text[1:] else: # Add any words after escape sequence words += m.group("posttext").split() result: list[str] = [] next_substr = "" # Iterate over all words. previous_fit = False for i in range(len(words)): if i == 0: pot_substr = words[i] else: # (optimistically) add the next word to check the fit pot_substr = " ".join([next_substr, words[i]]) # Find out if the pot(ential)_substr fits into the next substring. fits_first = len(result) == 0 and color_len(pot_substr) <= first_width fits_middle = len(result) != 0 and color_len(pot_substr) <= width if fits_first or fits_middle: # Fitted(!) let's try and add another word before appending next_substr = pot_substr previous_fit = True elif not fits_first and not fits_middle and previous_fit: # Extra word didn't fit, append what we have result.append(next_substr) next_substr = words[i] previous_fit = color_len(next_substr) <= width else: # Didn't fit anywhere if uncolorize(pot_substr) == pot_substr: # Simple uncolored string, append a cropped word if len(result) == 0: # Crop word by the first_width for the first line result.append(pot_substr[:first_width]) # add rest of word to next line next_substr = pot_substr[first_width:] else: result.append(pot_substr[:width]) next_substr = pot_substr[width:] else: # Colored strings if len(result) == 0: this_line, next_line = color_split(pot_substr, first_width) result.append(this_line) next_substr = next_line else: this_line, next_line = color_split(pot_substr, width) result.append(this_line) next_substr = next_line previous_fit = color_len(next_substr) <= width # We finished constructing the substrings, but the last substring # has not yet been added to the result. result.append(next_substr) return result def get_column_layout( indent_str: str, left: Side, right: Side, max_width: int, separator: str, ) -> Iterator[str]: """Print left & right data, with separator inbetween 'left' and 'right' have a structure of: {'prefix':u'','contents':u'','suffix':u'','width':0} In a column layout the printing will be: {indent_str}{lhs0}{separator}{rhs0} {lhs1 / padding }{rhs1} ... The first line of each column (i.e. {lhs0} or {rhs0}) is: {prefix}{part of contents}{suffix} With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the rest of contents, wrapped if the width would be otherwise exceeded. """ if left.width == -1 or right.width == -1: # If widths have not been defined, set to share space. width = (max_width - len(indent_str) - len(separator)) // 2 left = left._replace(width=width) right = right._replace(width=width) # On the first line, account for suffix as well as prefix left_width_without_prefix = left.width - left.prefix_width left_split = split_into_lines( left.contents, left_width_without_prefix - left.suffix_width, left_width_without_prefix, ) right_width_without_prefix = right.width - right.prefix_width right_split = split_into_lines( right.contents, right_width_without_prefix - right.suffix_width, right_width_without_prefix, ) max_line_count = max(len(left_split), len(right_split)) out = "" for i in range(max_line_count): # indentation out += indent_str # Prefix or indent_str for line if i == 0: out += left.prefix else: out += indent(left.prefix_width) # Line i of left hand side contents. if i < len(left_split): out += left_split[i] left_part_len = color_len(left_split[i]) else: left_part_len = 0 # Padding until end of column. # Note: differs from original # column calcs in not -1 afterwards for space # in track number as that is included in 'prefix' padding = left.width - left.prefix_width - left_part_len # Remove some padding on the first line to display # length if i == 0: padding -= left.suffix_width out += indent(padding) if i == 0: out += left.suffix # Separator between columns. if i == 0: out += separator else: out += indent(len(separator)) # Right prefix, contents, padding, suffix if i == 0: out += right.prefix else: out += indent(right.prefix_width) # Line i of right hand side. if i < len(right_split): out += right_split[i] right_part_len = color_len(right_split[i]) else: right_part_len = 0 # Padding until end of column padding = right.width - right.prefix_width - right_part_len # Remove some padding on the first line to display # length if i == 0: padding -= right.suffix_width out += indent(padding) # Length in first line if i == 0: out += right.suffix # Linebreak, except in the last line. if i < max_line_count - 1: out += "\n" # Constructed all of the columns, now print yield out def get_newline_layout( indent_str: str, left: Side, right: Side, max_width: int, separator: str, ) -> Iterator[str]: """Prints using a newline separator between left & right if they go over their allocated widths. The datastructures are shared with the column layout. In contrast to the column layout, the prefix and suffix are printed at the beginning and end of the contents. If no wrapping is required (i.e. everything fits) the first line will look exactly the same as the column layout: {indent}{lhs0}{separator}{rhs0} However if this would go over the width given, the layout now becomes: {indent}{lhs0} {indent}{separator}{rhs0} If {lhs0} would go over the maximum width, the subsequent lines are indented a second time for ease of reading. """ width_without_prefix = max_width - len(indent_str) width_without_double_prefix = max_width - 2 * len(indent_str) # On lower lines we will double the indent for clarity left_split = split_into_lines( left.rendered, width_without_prefix, width_without_double_prefix, ) # Repeat calculations for rhs, including separator on first line right_split = split_into_lines( right.rendered, width_without_prefix - len(separator), width_without_double_prefix, ) for i, line in enumerate(left_split): if i == 0: yield f"{indent_str}{line}" elif line != "": # Ignore empty lines yield f"{indent_str * 2}{line}" for i, line in enumerate(right_split): if i == 0: yield f"{indent_str}{separator}{line}" elif line != "": yield f"{indent_str * 2}{line}" def get_layout_method() -> Callable[[str, Side, Side, int, str], Iterator[str]]: return beets.config["ui"]["import"]["layout"].as_choice( {"column": get_column_layout, "newline": get_newline_layout} ) def get_layout_lines( indent_str: str, left: Side, right: Side, max_width: int, ) -> Iterator[str]: # No right hand information, so we don't need a separator. separator = "" if right.rendered == "" else " -> " first_line_no_wrap = ( f"{indent_str}{left.rendered}{separator}{right.rendered}" ) if color_len(first_line_no_wrap) < max_width: # Everything fits, print out line. yield first_line_no_wrap else: layout_method = get_layout_method() yield from layout_method(indent_str, left, right, max_width, separator) ================================================ FILE: beets/util/lyrics.py ================================================ from __future__ import annotations import re from contextlib import suppress from dataclasses import dataclass, field from functools import cached_property from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from beets.util import unique_list if TYPE_CHECKING: from beets.library import Item INSTRUMENTAL_LYRICS = "[Instrumental]" BACKEND_NAMES = {"genius", "musixmatch", "lrclib", "tekstowo"} @dataclass class Lyrics: """Represent lyrics text together with structured source metadata. This value object keeps the canonical lyrics body, optional provenance, and optional translation metadata synchronized across fetching, translation, and persistence. """ ORIGINAL_PAT = re.compile(r"[^\n]+ / ") TRANSLATION_PAT = re.compile(r" / [^\n]+") LINE_PARTS_PAT = re.compile(r"^(\[\d\d:\d\d\.\d\d\]|) *(.*)$") text: str backend: str | None = None url: str | None = None language: str | None = None translation_language: str | None = None translations: list[str] = field(default_factory=list) def __post_init__(self) -> None: """Populate missing language metadata from the current text.""" try: import langdetect except ImportError: return # Set seed to 0 for deterministic results langdetect.DetectorFactory.seed = 0 if not self.text or self.text == INSTRUMENTAL_LYRICS: return if not self.language: with suppress(langdetect.LangDetectException): self.language = langdetect.detect(self.original_text).upper() if not self.translation_language: all_lines = self.text.splitlines() lines_with_delimiter_count = sum( 1 for ln in all_lines if " / " in ln ) if lines_with_delimiter_count >= len(all_lines) / 2: # we are confident we are dealing with translations with suppress(langdetect.LangDetectException): self.translation_language = langdetect.detect( self.ORIGINAL_PAT.sub("", self.text) ).upper() @classmethod def from_legacy_text(cls, text: str) -> Lyrics: """Build lyrics from legacy text that may include an inline source.""" data: dict[str, Any] = {} data["text"], *suffix = text.split("\n\nSource: ") if suffix: url = suffix[0].strip() url_root = urlparse(url).netloc.removeprefix("www.").split(".")[0] data.update( url=url, backend=url_root if url_root in BACKEND_NAMES else "google", ) return cls(**data) @classmethod def from_item(cls, item: Item) -> Lyrics: """Build lyrics from an item's canonical text and flexible metadata.""" data = {"text": item.lyrics} for key in ("backend", "url", "language", "translation_language"): data[key] = item.get(f"lyrics_{key}", with_album=False) return cls(**data) @cached_property def original_text(self) -> str: """Return the original text without translations.""" # Remove translations from the lyrics text. return self.TRANSLATION_PAT.sub("", self.text).strip() @cached_property def _split_lines(self) -> list[tuple[str, str]]: """Split lyrics into timestamp/text pairs for line-wise processing. Timestamps, when present, are kept separate so callers can translate or normalize text without losing synced timing information. """ return [ (m[1], m[2]) if (m := self.LINE_PARTS_PAT.match(line)) else ("", "") for line in self.text.splitlines() ] @cached_property def timestamps(self) -> list[str]: """Return per-line timestamp prefixes from the lyrics text.""" return [ts for ts, _ in self._split_lines] @cached_property def text_lines(self) -> list[str]: """Return per-line lyric text with timestamps removed.""" return [ln for _, ln in self._split_lines] @property def synced(self) -> bool: """Return whether the lyrics contain synced timestamp markers.""" return any(self.timestamps) @property def translated(self) -> bool: """Return whether translation metadata is available.""" return bool(self.translation_language) @property def full_text(self) -> str: """Return canonical text with translations merged when available.""" if not self.translations: return self.text text_pairs = list(zip(self.text_lines, self.translations)) # only add the separator for non-empty and differing translations texts = [" / ".join(unique_list(filter(None, p))) for p in text_pairs] # only add the space between non-empty timestamps and texts return "\n".join( " ".join(filter(None, p)) for p in zip(self.timestamps, texts) ) ================================================ FILE: beets/util/m3u.py ================================================ # This file is part of beets. # Copyright 2022, J0J0 Todos. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides utilities to read, write and manipulate m3u playlist files.""" import traceback from beets.util import FilesystemError, mkdirall, normpath, syspath class EmptyPlaylistError(Exception): """Raised when a playlist file without media files is saved or loaded.""" pass class M3UFile: """Reads and writes m3u or m3u8 playlist files.""" def __init__(self, path): """``path`` is the absolute path to the playlist file. The playlist file type, m3u or m3u8 is determined by 1) the ending being m3u8 and 2) the file paths contained in the list being utf-8 encoded. Since the list is passed from the outside, this is currently out of control of this class. """ self.path = path self.extm3u = False self.media_list = [] def load(self): """Reads the m3u file from disk and sets the object's attributes.""" pl_normpath = normpath(self.path) try: with open(syspath(pl_normpath), "rb") as pl_file: raw_contents = pl_file.readlines() except OSError as exc: raise FilesystemError( exc, "read", (pl_normpath,), traceback.format_exc() ) self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False for line in raw_contents[1:]: if line.startswith(b"#"): # Support for specific EXTM3U comments could be added here. continue self.media_list.append(normpath(line.rstrip())) if not self.media_list: raise EmptyPlaylistError def set_contents(self, media_list, extm3u=True): """Sets self.media_list to a list of media file paths. Also sets additional flags, changing the final m3u-file's format. ``media_list`` is a list of paths to media files that should be added to the playlist (relative or absolute paths, that's the responsibility of the caller). By default the ``extm3u`` flag is set, to ensure a save-operation writes an m3u-extended playlist (comment "#EXTM3U" at the top of the file). """ self.media_list = media_list self.extm3u = extm3u def write(self): """Writes the m3u file to disk. Handles the creation of potential parent directories. """ header = [b"#EXTM3U"] if self.extm3u else [] if not self.media_list: raise EmptyPlaylistError contents = header + self.media_list pl_normpath = normpath(self.path) mkdirall(pl_normpath) try: with open(syspath(pl_normpath), "wb") as pl_file: for line in contents: pl_file.write(line + b"\n") pl_file.write(b"\n") # Final linefeed to prevent noeol file. except OSError as exc: raise FilesystemError( exc, "create", (pl_normpath,), traceback.format_exc() ) ================================================ FILE: beets/util/pipeline.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Simple but robust implementation of generator/coroutine-based pipelines in Python. The pipelines may be run either sequentially (single-threaded) or in parallel (one thread per pipeline stage). This implementation supports pipeline bubbles (indications that the processing for a certain item should abort). To use them, yield the BUBBLE constant from any stage coroutine except the last. In the parallel case, the implementation transparently handles thread shutdown when the processing is complete and when a stage raises an exception. KeyboardInterrupts (^C) are also handled. When running a parallel pipeline, it is also possible to use multiple coroutines for the same pipeline stage; this lets you speed up a bottleneck stage by dividing its work among multiple threads. To do so, pass an iterable of coroutines to the Pipeline constructor in place of any single coroutine. """ from __future__ import annotations import queue import sys from threading import Lock, Thread from typing import TYPE_CHECKING, TypeVar from typing_extensions import TypeVarTuple, Unpack if TYPE_CHECKING: from collections.abc import Callable, Generator BUBBLE = "__PIPELINE_BUBBLE__" POISON = "__PIPELINE_POISON__" DEFAULT_QUEUE_SIZE = 16 Tq = TypeVar("Tq") def _invalidate_queue(q, val=None, sync=True): """Breaks a Queue such that it never blocks, always has size 1, and has no maximum size. get()ing from the queue returns `val`, which defaults to None. `sync` controls whether a lock is required (because it's not reentrant!). """ def _qsize(len=len): return 1 def _put(item): pass def _get(): return val if sync: q.mutex.acquire() try: # Originally, we set `maxsize` to 0 here, which is supposed to mean # an unlimited queue size. However, there is a race condition since # Python 3.2 when this attribute is changed while another thread is # waiting in put()/get() due to a full/empty queue. # Setting it to 2 is still hacky because Python does not give any # guarantee what happens if Queue methods/attributes are overwritten # when it is already in use. However, because of our dummy _put() # and _get() methods, it provides a workaround to let the queue appear # to be never empty or full. # See issue https://github.com/beetbox/beets/issues/2078 q.maxsize = 2 q._qsize = _qsize q._put = _put q._get = _get q.not_empty.notify_all() q.not_full.notify_all() finally: if sync: q.mutex.release() class CountedQueue(queue.Queue[Tq]): """A queue that keeps track of the number of threads that are still feeding into it. The queue is poisoned when all threads are finished with the queue. """ def __init__(self, maxsize=0): queue.Queue.__init__(self, maxsize) self.nthreads = 0 self.poisoned = False def acquire(self): """Indicate that a thread will start putting into this queue. Should not be called after the queue is already poisoned. """ with self.mutex: assert not self.poisoned assert self.nthreads >= 0 self.nthreads += 1 def release(self): """Indicate that a thread that was putting into this queue has exited. If this is the last thread using the queue, the queue is poisoned. """ with self.mutex: self.nthreads -= 1 assert self.nthreads >= 0 if self.nthreads == 0: # All threads are done adding to this queue. Poison it # when it becomes empty. self.poisoned = True # Replacement _get invalidates when no items remain. _old_get = self._get def _get(): out = _old_get() if not self.queue: _invalidate_queue(self, POISON, False) return out if self.queue: # Items remain. self._get = _get else: # No items. Invalidate immediately. _invalidate_queue(self, POISON, False) class MultiMessage: """A message yielded by a pipeline stage encapsulating multiple values to be sent to the next stage. """ def __init__(self, messages): self.messages = messages def multiple(messages): """Yield multiple([message, ..]) from a pipeline stage to send multiple values to the next pipeline stage. """ return MultiMessage(messages) A = TypeVarTuple("A") # Arguments of a function (omitting the task) T = TypeVar("T") # Type of the task # Normally these are concatenated i.e. (*args, task) # Return type of the function (should normally be task but sadly # we cant enforce this with the current stage functions without # a refactor) R = TypeVar("R") def stage( func: Callable[ [Unpack[A], T], R | None, ], ): """Decorate a function to become a simple stage. >>> @stage ... def add(n, i): ... return i + n >>> pipe = Pipeline([ ... iter([1, 2, 3]), ... add(2), ... ]) >>> list(pipe.pull()) [3, 4, 5] """ def coro(*args: Unpack[A]) -> Generator[R | T | None, T, None]: task: R | T | None = None while True: task = yield task task = func(*args, task) return coro def mutator_stage(func: Callable[[Unpack[A], T], R]): """Decorate a function that manipulates items in a coroutine to become a simple stage. >>> @mutator_stage ... def setkey(key, item): ... item[key] = True >>> pipe = Pipeline([ ... iter([{'x': False}, {'a': False}]), ... setkey('x'), ... ]) >>> list(pipe.pull()) [{'x': True}, {'a': False, 'x': True}] """ def coro(*args: Unpack[A]) -> Generator[T | None, T, None]: task = None while True: task = yield task func(*args, task) return coro def _allmsgs(obj): """Returns a list of all the messages encapsulated in obj. If obj is a MultiMessage, returns its enclosed messages. If obj is BUBBLE, returns an empty list. Otherwise, returns a list containing obj. """ if isinstance(obj, MultiMessage): return obj.messages elif obj == BUBBLE: return [] else: return [obj] class PipelineThread(Thread): """Abstract base class for pipeline-stage threads.""" def __init__(self, all_threads): super().__init__() self.abort_lock = Lock() self.abort_flag = False self.all_threads = all_threads self.exc_info = None def abort(self): """Shut down the thread at the next chance possible.""" with self.abort_lock: self.abort_flag = True # Ensure that we are not blocking on a queue read or write. if hasattr(self, "in_queue"): _invalidate_queue(self.in_queue, POISON) if hasattr(self, "out_queue"): _invalidate_queue(self.out_queue, POISON) def abort_all(self, exc_info): """Abort all other threads in the system for an exception.""" self.exc_info = exc_info for thread in self.all_threads: thread.abort() class FirstPipelineThread(PipelineThread): """The thread running the first stage in a parallel pipeline setup. The coroutine should just be a generator. """ def __init__(self, coro, out_queue, all_threads): super().__init__(all_threads) self.coro = coro self.out_queue = out_queue self.out_queue.acquire() def run(self): try: while True: with self.abort_lock: if self.abort_flag: return # Get the value from the generator. try: msg = next(self.coro) except StopIteration: break # Send messages to the next stage. for msg in _allmsgs(msg): with self.abort_lock: if self.abort_flag: return self.out_queue.put(msg) except BaseException: self.abort_all(sys.exc_info()) return # Generator finished; shut down the pipeline. self.out_queue.release() class MiddlePipelineThread(PipelineThread): """A thread running any stage in the pipeline except the first or last. """ def __init__(self, coro, in_queue, out_queue, all_threads): super().__init__(all_threads) self.coro = coro self.in_queue = in_queue self.out_queue = out_queue self.out_queue.acquire() def run(self): try: # Prime the coroutine. next(self.coro) while True: with self.abort_lock: if self.abort_flag: return # Get the message from the previous stage. msg = self.in_queue.get() if msg is POISON: break with self.abort_lock: if self.abort_flag: return # Invoke the current stage. out = self.coro.send(msg) # Send messages to next stage. for msg in _allmsgs(out): with self.abort_lock: if self.abort_flag: return self.out_queue.put(msg) except BaseException: self.abort_all(sys.exc_info()) return # Pipeline is shutting down normally. self.out_queue.release() class LastPipelineThread(PipelineThread): """A thread running the last stage in a pipeline. The coroutine should yield nothing. """ def __init__(self, coro, in_queue, all_threads): super().__init__(all_threads) self.coro = coro self.in_queue = in_queue def run(self): # Prime the coroutine. next(self.coro) try: while True: with self.abort_lock: if self.abort_flag: return # Get the message from the previous stage. msg = self.in_queue.get() if msg is POISON: break with self.abort_lock: if self.abort_flag: return # Send to consumer. self.coro.send(msg) except BaseException: self.abort_all(sys.exc_info()) return class Pipeline: """Represents a staged pattern of work. Each stage in the pipeline is a coroutine that receives messages from the previous stage and yields messages to be sent to the next stage. """ def __init__(self, stages): """Makes a new pipeline from a list of coroutines. There must be at least two stages. """ if len(stages) < 2: raise ValueError("pipeline must have at least two stages") self.stages = [] for stage in stages: if isinstance(stage, (list, tuple)): self.stages.append(stage) else: # Default to one thread per stage. self.stages.append((stage,)) def run_sequential(self): """Run the pipeline sequentially in the current thread. The stages are run one after the other. Only the first coroutine in each stage is used. """ list(self.pull()) def run_parallel(self, queue_size=DEFAULT_QUEUE_SIZE): """Run the pipeline in parallel using one thread per stage. The messages between the stages are stored in queues of the given size. """ queue_count = len(self.stages) - 1 queues = [CountedQueue(queue_size) for i in range(queue_count)] threads = [] # Set up first stage. for coro in self.stages[0]: threads.append(FirstPipelineThread(coro, queues[0], threads)) # Middle stages. for i in range(1, queue_count): for coro in self.stages[i]: threads.append( MiddlePipelineThread( coro, queues[i - 1], queues[i], threads ) ) # Last stage. for coro in self.stages[-1]: threads.append(LastPipelineThread(coro, queues[-1], threads)) # Start threads. for thread in threads: thread.start() # Wait for termination. The final thread lasts the longest. try: # Using a timeout allows us to receive KeyboardInterrupt # exceptions during the join(). while threads[-1].is_alive(): threads[-1].join(1) except BaseException: # Stop all the threads immediately. for thread in threads: thread.abort() raise finally: # Make completely sure that all the threads have finished # before we return. They should already be either finished, # in normal operation, or aborted, in case of an exception. for thread in threads[:-1]: thread.join() for thread in threads: exc_info = thread.exc_info if exc_info: # Make the exception appear as it was raised originally. raise exc_info[1].with_traceback(exc_info[2]) def pull(self): """Yield elements from the end of the pipeline. Runs the stages sequentially until the last yields some messages. Each of the messages is then yielded by ``pulled.next()``. If the pipeline has a consumer, that is the last stage does not yield any messages, then pull will not yield any messages. Only the first coroutine in each stage is used """ coros = [stage[0] for stage in self.stages] # "Prime" the coroutines. for coro in coros[1:]: next(coro) # Begin the pipeline. for out in coros[0]: msgs = _allmsgs(out) for coro in coros[1:]: next_msgs = [] for msg in msgs: out = coro.send(msg) next_msgs.extend(_allmsgs(out)) msgs = next_msgs for msg in msgs: yield msg ================================================ FILE: beets/util/units.py ================================================ import re def raw_seconds_short(string: str) -> float: """Formats a human-readable M:SS string as a float (number of seconds). Raises ValueError if the conversion cannot take place due to `string` not being in the right format. """ match = re.match(r"^(\d+):([0-5]\d)$", string) if not match: raise ValueError("String not in M:SS format") minutes, seconds = map(int, match.groups()) return float(minutes * 60 + seconds) def human_seconds_short(interval): """Formats a number of seconds as a short human-readable M:SS string. """ interval = int(interval) return f"{interval // 60}:{interval % 60:02d}" def human_bytes(size): """Formats size, a number of bytes, in a human-readable way.""" powers = ["", "K", "M", "G", "T", "P", "E", "Z", "Y", "H"] unit = "B" for power in powers: if size < 1024: return f"{size:3.1f} {power}{unit}" size /= 1024.0 unit = "iB" return "big" def human_seconds(interval): """Formats interval, a number of seconds, as a human-readable time interval using English words. """ units = [ (1, "second"), (60, "minute"), (60, "hour"), (24, "day"), (7, "week"), (52, "year"), (10, "decade"), ] for i in range(len(units) - 1): increment, suffix = units[i] next_increment, _ = units[i + 1] interval /= float(increment) if interval < next_increment: break else: # Last unit. increment, suffix = units[-1] interval /= float(increment) return f"{interval:3.1f} {suffix}s" ================================================ FILE: beetsplug/_typing.py ================================================ from __future__ import annotations from typing import Any from typing_extensions import NotRequired, TypedDict JSONDict = dict[str, Any] class LRCLibAPI: class Item(TypedDict): """Lyrics data item returned by the LRCLib API.""" id: int name: str trackName: str artistName: str albumName: str duration: float | None instrumental: bool plainLyrics: str syncedLyrics: str | None class GeniusAPI: """Genius API data types. This documents *only* the fields that are used in the plugin. :attr:`SearchResult` is an exception, since I thought some of the other fields might be useful in the future. """ class DateComponents(TypedDict): year: int month: int day: int class Artist(TypedDict): api_path: str header_image_url: str id: int image_url: str is_meme_verified: bool is_verified: bool name: str url: str class Stats(TypedDict): unreviewed_annotations: int hot: bool class SearchResult(TypedDict): annotation_count: int api_path: str artist_names: str full_title: str header_image_thumbnail_url: str header_image_url: str id: int lyrics_owner_id: int lyrics_state: str path: str primary_artist_names: str pyongs_count: int | None relationships_index_url: str release_date_components: GeniusAPI.DateComponents release_date_for_display: str release_date_with_abbreviated_month_for_display: str song_art_image_thumbnail_url: str song_art_image_url: str stats: GeniusAPI.Stats title: str title_with_featured: str url: str featured_artists: list[GeniusAPI.Artist] primary_artist: GeniusAPI.Artist primary_artists: list[GeniusAPI.Artist] class SearchHit(TypedDict): result: GeniusAPI.SearchResult class SearchResponse(TypedDict): hits: list[GeniusAPI.SearchHit] class Search(TypedDict): response: GeniusAPI.SearchResponse class StatusResponse(TypedDict): status: int message: str class Meta(TypedDict): meta: GeniusAPI.StatusResponse Response = Search | Meta class GoogleCustomSearchAPI: class Response(TypedDict): """Search response from the Google Custom Search API. If the search returns no results, the :attr:`items` field is not found. """ items: NotRequired[list[GoogleCustomSearchAPI.Item]] class Item(TypedDict): """A Google Custom Search API result item. :attr:`title` field is shown to the user in the search interface, thus it gets truncated with an ellipsis for longer queries. For most results, the full title is available as ``og:title`` metatag found under the :attr:`pagemap` field. Note neither this metatag nor the ``pagemap`` field is guaranteed to be present in the data. """ title: str link: str pagemap: NotRequired[GoogleCustomSearchAPI.Pagemap] class Pagemap(TypedDict): """Pagemap data with a single meta tags dict in a list.""" metatags: list[JSONDict] class TranslatorAPI: class Language(TypedDict): """Language data returned by the translator API.""" language: str score: float class Translation(TypedDict): """Translation data returned by the translator API.""" text: str to: str class Response(TypedDict): """Response from the translator API.""" detectedLanguage: TranslatorAPI.Language translations: list[TranslatorAPI.Translation] ================================================ FILE: beetsplug/_utils/__init__.py ================================================ from . import art, vfs __all__ = ["art", "vfs"] ================================================ FILE: beetsplug/_utils/art.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """High-level utilities for manipulating image files associated with music and items' embedded album art. """ import os from tempfile import NamedTemporaryFile import mediafile from beets.util import bytestring_path, displayable_path, syspath from beets.util.artresizer import ArtResizer def mediafile_image(image_path, maxwidth=None): """Return a `mediafile.Image` object for the path.""" with open(syspath(image_path), "rb") as f: data = f.read() return mediafile.Image(data, type=mediafile.ImageType.front) def get_art(log, item): # Extract the art. try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.warning("Could not extract art from {.filepath}: {}", item, exc) return return mf.art def embed_item( log, item, imagepath, maxwidth=None, itempath=None, compare_threshold=0, ifempty=False, as_album=False, id3v23=None, quality=0, ): """Embed an image into the item's media file.""" # Conditions. if compare_threshold: is_similar = check_art_similarity( log, item, imagepath, compare_threshold ) if is_similar is None: log.warning("Error while checking art similarity; skipping.") return elif not is_similar: log.info("Image not similar; skipping.") return if ifempty and get_art(log, item): log.info("media file already contained art") return # Filters. if maxwidth and not as_album: imagepath = resize_image(log, imagepath, maxwidth, quality) # Get the `Image` object from the file. try: log.debug("embedding {}", displayable_path(imagepath)) image = mediafile_image(imagepath, maxwidth) except OSError as exc: log.warning("could not read image file: {}", exc) return # Make sure the image kind is safe (some formats only support PNG # and JPEG). if image.mime_type not in ("image/jpeg", "image/png"): log.info("not embedding image of unsupported type: {.mime_type}", image) return item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23) def embed_album( log, album, maxwidth=None, quiet=False, compare_threshold=0, ifempty=False, quality=0, ): """Embed album art into all of the album's items.""" imagepath = album.artpath if not imagepath: log.info("No album art present for {}", album) return if not os.path.isfile(syspath(imagepath)): log.info( "Album art not found at {} for {}", displayable_path(imagepath), album, ) return if maxwidth: imagepath = resize_image(log, imagepath, maxwidth, quality) log.info("Embedding album art into {}", album) for item in album.items(): embed_item( log, item, imagepath, maxwidth, None, compare_threshold, ifempty, as_album=True, quality=quality, ) def resize_image(log, imagepath, maxwidth, quality): """Returns path to an image resized to maxwidth and encoded with the specified quality level. """ log.debug( "Resizing album art to {} pixels wide and encoding at quality level {}", maxwidth, quality, ) imagepath = ArtResizer.shared.resize( maxwidth, syspath(imagepath), quality=quality ) return imagepath def check_art_similarity( log, item, imagepath, compare_threshold, artresizer=None, ): """A boolean indicating if an image is similar to embedded item art. If no embedded art exists, always return `True`. If the comparison fails for some reason, the return value is `None`. This must only be called if `ArtResizer.shared.can_compare` is `True`. """ with NamedTemporaryFile(delete=True) as f: art = extract(log, f.name, item) if not art: return True if artresizer is None: artresizer = ArtResizer.shared return artresizer.compare(art, imagepath, compare_threshold) def extract(log, outpath, item): art = get_art(log, item) outpath = bytestring_path(outpath) if not art: log.info("No album art present in {}, skipping.", item) return # Add an extension to the filename. ext = mediafile.image_extension(art) if not ext: log.warning("Unknown image type in {.filepath}.", item) return outpath += bytestring_path(f".{ext}") log.info( "Extracting album art from: {} to: {}", item, displayable_path(outpath), ) with open(syspath(outpath), "wb") as f: f.write(art) return outpath def extract_first(log, outpath, items): for item in items: real_path = extract(log, outpath, item) if real_path: return real_path def clear_item(item, log): if mediafile.MediaFile(syspath(item.path)).images: log.debug("Clearing art for {}", item) item.try_write(tags={"images": None}) def clear(log, lib, query): items = lib.items(query) log.info("Clearing album art from {} items", len(items)) for item in items: clear_item(item, log) ================================================ FILE: beetsplug/_utils/musicbrainz.py ================================================ """Helpers for communicating with the MusicBrainz webservice. Provides rate-limited HTTP session and convenience methods to fetch and normalize API responses. This module centralizes request handling and response shaping so callers can work with consistently structured data without embedding HTTP or rate-limit logic throughout the codebase. """ from __future__ import annotations import operator import re from dataclasses import dataclass, field from functools import cached_property, singledispatchmethod, wraps from itertools import groupby, starmap from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar from requests_ratelimiter import LimiterMixin from typing_extensions import NotRequired, Unpack from beets import config, logging from .requests import RequestHandler, TimeoutAndRetrySession if TYPE_CHECKING: from collections.abc import Callable from requests import Response from beets.metadata_plugins import IDResponse from .._typing import JSONDict log = logging.getLogger("beets") LUCENE_SPECIAL_CHAR_PAT = re.compile(r'([-+&|!(){}[\]^"~*?:\\/])') RELEASE_INCLUDES = [ "artists", "media", "recordings", "release-groups", "labels", "artist-credits", "aliases", "recording-level-rels", "work-rels", "work-level-rels", "artist-rels", "isrcs", "url-rels", "release-rels", "genres", "tags", ] RECORDING_INCLUDES = [ "artists", "aliases", "isrcs", "work-level-rels", "artist-rels", ] class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession): """HTTP session that enforces rate limits.""" Entity = Literal[ "area", "artist", "collection", "event", "genre", "instrument", "label", "place", "recording", "release", "release-group", "series", "work", "url", ] class LookupKwargs(TypedDict, total=False): includes: NotRequired[list[str]] class PagingKwargs(TypedDict, total=False): limit: NotRequired[int] offset: NotRequired[int] class SearchKwargs(PagingKwargs): query: NotRequired[str] class BrowseKwargs(LookupKwargs, PagingKwargs, total=False): pass class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False): artist: NotRequired[str] collection: NotRequired[str] release: NotRequired[str] type: NotRequired[str] class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False): work: NotRequired[str] P = ParamSpec("P") R = TypeVar("R") def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]: required = frozenset(keys) def deco(func: Callable[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # kwargs is a real dict at runtime; safe to inspect here if not required & kwargs.keys(): required_str = ", ".join(sorted(required)) raise ValueError( f"At least one of {required_str} filter is required" ) return func(*args, **kwargs) return wrapper return deco @dataclass class MusicBrainzAPI(RequestHandler): """High-level interface to the MusicBrainz WS/2 API. Responsibilities: - Configure the API host and request rate from application configuration. - Offer helpers to fetch common entity types and to run searches. - Normalize MusicBrainz responses so relation lists are grouped by target type for easier downstream consumption. Documentation: https://musicbrainz.org/doc/MusicBrainz_API """ api_host: str = field(init=False) rate_limit: float = field(init=False) def __post_init__(self) -> None: mb_config = config["musicbrainz"] mb_config.add( { "host": "musicbrainz.org", "https": False, "ratelimit": 1, "ratelimit_interval": 1, } ) hostname = mb_config["host"].as_str() if hostname == "musicbrainz.org": self.api_host, self.rate_limit = "https://musicbrainz.org", 1.0 else: https = mb_config["https"].get(bool) self.api_host = f"http{'s' if https else ''}://{hostname}" self.rate_limit = ( mb_config["ratelimit"].get(int) / mb_config["ratelimit_interval"].as_number() ) @cached_property def api_root(self) -> str: return f"{self.api_host}/ws/2" def create_session(self) -> LimiterTimeoutSession: return LimiterTimeoutSession(per_second=self.rate_limit) def request(self, *args, **kwargs) -> Response: """Ensure all requests specify JSON response format by default.""" kwargs.setdefault("params", {}) kwargs["params"]["fmt"] = "json" return super().request(*args, **kwargs) def _get_resource( self, resource: str, includes: list[str] | None = None, **kwargs ) -> JSONDict: """Retrieve and normalize data from the API resource endpoint. If requested, includes are appended to the request. The response is passed through a normalizer that groups relation entries by their target type so that callers receive a consistently structured mapping. """ if includes: kwargs["inc"] = "+".join(includes) return self._group_relations( self.get_json(f"{self.api_root}/{resource}", params=kwargs) ) def _lookup( self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs] ) -> JSONDict: return self._get_resource(f"{entity}/{id_}", **kwargs) def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]: return self._get_resource(entity, **kwargs).get(f"{entity}s", []) @staticmethod def format_search_term(field: str, term: str) -> str: """Format a search term for the MusicBrainz API. See https://lucene.apache.org/core/4_3_0/queryparser/org/apache/lucene/queryparser/classic/package-summary.html """ if not (term := term.lower().strip()): return "" term = LUCENE_SPECIAL_CHAR_PAT.sub(r"\\\1", term) if field: term = f"{field}:({term})" return term def search( self, entity: Entity, filters: dict[str, str], **kwargs: Unpack[SearchKwargs], ) -> list[IDResponse]: """Search for MusicBrainz entities matching the given filters. * Query is constructed by combining the provided filters using AND logic * Each filter key-value pair is formatted as 'key:"value"' unless - 'key' is empty, in which case only the value is used, '"value"' - 'value' is empty, in which case the filter is ignored * Values are lowercased and stripped of whitespace. """ query = " ".join( filter(None, starmap(self.format_search_term, filters.items())) ) log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query) kwargs["query"] = query return self._get_resource(entity, **kwargs)[f"{entity}s"] def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: """Retrieve a release by its MusicBrainz ID.""" kwargs.setdefault("includes", RELEASE_INCLUDES) return self._lookup("release", id_, **kwargs) def get_recording( self, id_: str, **kwargs: Unpack[LookupKwargs] ) -> JSONDict: """Retrieve a recording by its MusicBrainz ID.""" kwargs.setdefault("includes", RECORDING_INCLUDES) return self._lookup("recording", id_, **kwargs) def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict: """Retrieve a work by its MusicBrainz ID.""" return self._lookup("work", id_, **kwargs) @require_one_of("artist", "collection", "release", "work") def browse_recordings( self, **kwargs: Unpack[BrowseRecordingsKwargs] ) -> list[JSONDict]: """Browse recordings related to the given entities. At least one of artist, collection, release, or work must be provided. """ return self._browse("recording", **kwargs) @require_one_of("artist", "collection", "release") def browse_release_groups( self, **kwargs: Unpack[BrowseReleaseGroupsKwargs] ) -> list[JSONDict]: """Browse release groups related to the given entities. At least one of artist, collection, or release must be provided. Optionally filter by type (e.g., "album|ep"). """ return self._browse("release-group", **kwargs) @singledispatchmethod @classmethod def _group_relations(cls, data: Any) -> Any: """Normalize MusicBrainz 'relations' into type-keyed fields recursively. This helper rewrites payloads that use a generic 'relations' list into a structure that is easier to consume downstream. When a mapping contains 'relations', those entries are regrouped by their 'target-type' and stored under keys like '-relations'. The original 'relations' key is removed to avoid ambiguous access patterns. The transformation is applied recursively so that nested objects and sequences are normalized consistently, while non-container values are left unchanged. """ return data @_group_relations.register(list) @classmethod def _(cls, data: list[Any]) -> list[Any]: return [cls._group_relations(i) for i in data] @_group_relations.register(dict) @classmethod def _(cls, data: JSONDict) -> JSONDict: for k, v in list(data.items()): if k == "relations": get_target_type = operator.methodcaller("get", "target-type") for target_type, group in groupby( sorted(v, key=get_target_type), get_target_type ): relations = [ {k: v for k, v in item.items() if k != "target-type"} for item in group ] data[f"{target_type}-relations"] = cls._group_relations( relations ) data.pop("relations") else: data[k] = cls._group_relations(v) return data class MusicBrainzAPIMixin: """Mixin that provides a cached MusicBrainzAPI helper instance.""" @cached_property def mb_api(self) -> MusicBrainzAPI: return MusicBrainzAPI() ================================================ FILE: beetsplug/_utils/requests.py ================================================ from __future__ import annotations import atexit import threading from contextlib import contextmanager from functools import cached_property from http import HTTPStatus from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from beets import __version__ if TYPE_CHECKING: from collections.abc import Iterator class BeetsHTTPError(requests.exceptions.HTTPError): STATUS: ClassVar[HTTPStatus] def __init__(self, *args, **kwargs) -> None: super().__init__( f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}", *args, **kwargs, ) class HTTPNotFoundError(BeetsHTTPError): STATUS = HTTPStatus.NOT_FOUND class Closeable(Protocol): """Protocol for objects that have a close method.""" def close(self) -> None: ... C = TypeVar("C", bound=Closeable) class SingletonMeta(type, Generic[C]): """Metaclass ensuring a single shared instance per class. Creates one instance per class type on first instantiation, reusing it for all subsequent calls. Automatically registers cleanup on program exit for proper resource management. """ _instances: ClassVar[dict[type[Any], Any]] = {} _lock: ClassVar[threading.Lock] = threading.Lock() def __call__(cls, *args: Any, **kwargs: Any) -> C: if cls not in cls._instances: with cls._lock: if cls not in SingletonMeta._instances: instance = super().__call__(*args, **kwargs) SingletonMeta._instances[cls] = instance atexit.register(instance.close) return SingletonMeta._instances[cls] class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta): """HTTP session with sensible defaults. * default beets User-Agent header * default request timeout * automatic retries on transient connection or server errors * raises exceptions for HTTP error status codes """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/" retry = Retry( total=6, backoff_factor=0.5, # Retry on server errors status_forcelist=[ HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.BAD_GATEWAY, HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.GATEWAY_TIMEOUT, ], ) adapter = HTTPAdapter(max_retries=retry) self.mount("https://", adapter) self.mount("http://", adapter) def request(self, *args, **kwargs): """Execute HTTP request with automatic timeout and status validation. Ensures all requests have a timeout (defaults to 10 seconds) and raises an exception for HTTP error status codes. """ kwargs.setdefault("timeout", 10) r = super().request(*args, **kwargs) r.raise_for_status() return r class RequestHandler: """Manages HTTP requests with custom error handling and session management. Provides a reusable interface for making HTTP requests with automatic conversion of standard HTTP errors to beets-specific exceptions. Supports custom session types and error mappings that can be overridden by subclasses. Usage: Subclass and override :class:`RequestHandler.create_session`, :class:`RequestHandler.explicit_http_errors` or :class:`RequestHandler.status_to_error()` to customize behavior. Use - :class:`RequestHandler.get_json()` to get JSON response data - :class:`RequestHandler.get()` to get HTTP response object - :class:`RequestHandler.request()` to invoke arbitrary HTTP methods Feel free to define common methods that are used in multiple plugins. """ #: List of custom exceptions to be raised for specific status codes. explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [ HTTPNotFoundError ] def create_session(self) -> TimeoutAndRetrySession: """Create a new HTTP session instance. Can be overridden by subclasses to provide custom session types. """ return TimeoutAndRetrySession() @cached_property def session(self) -> TimeoutAndRetrySession: return self.create_session() def status_to_error( self, code: int ) -> type[requests.exceptions.HTTPError] | None: """Map HTTP status codes to beets-specific exception types. Searches the configured explicit HTTP errors for a matching status code. Returns None if no specific error type is registered for the given code. """ return next( (e for e in self.explicit_http_errors if e.STATUS == code), None ) @contextmanager def handle_http_error(self) -> Iterator[None]: """Convert standard HTTP errors to beets-specific exceptions. Wraps operations that may raise HTTPError, automatically translating recognized status codes into their corresponding beets exception types. Unrecognized errors are re-raised unchanged. """ try: yield except requests.exceptions.HTTPError as e: if beets_error := self.status_to_error(e.response.status_code): raise beets_error(response=e.response) from e raise def request(self, *args, **kwargs) -> requests.Response: """Perform HTTP request using the session with automatic error handling. Delegates to the underlying session method while converting recognized HTTP errors to beets-specific exceptions through the error handler. """ with self.handle_http_error(): return self.session.request(*args, **kwargs) def get(self, *args, **kwargs) -> requests.Response: """Perform HTTP GET request with automatic error handling.""" return self.request("get", *args, **kwargs) def put(self, *args, **kwargs) -> requests.Response: """Perform HTTP PUT request with automatic error handling.""" return self.request("put", *args, **kwargs) def delete(self, *args, **kwargs) -> requests.Response: """Perform HTTP DELETE request with automatic error handling.""" return self.request("delete", *args, **kwargs) def get_json(self, *args, **kwargs): """Fetch and parse JSON data from an HTTP endpoint.""" return self.get(*args, **kwargs).json() ================================================ FILE: beetsplug/_utils/vfs.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A simple utility for constructing filesystem-like trees from beets libraries. """ from __future__ import annotations from typing import TYPE_CHECKING, NamedTuple from beets import util if TYPE_CHECKING: from beets.library import Library class Node(NamedTuple): files: dict[str, int] # Maps filenames to Item ids. dirs: dict[str, Node] # Maps directory names to child nodes. def _insert(node: Node, path: list[str], itemid: int): """Insert an item into a virtual filesystem node.""" if len(path) == 1: # Last component. Insert file. node.files[path[0]] = itemid else: # In a directory. dirname = path[0] rest = path[1:] if dirname not in node.dirs: node.dirs[dirname] = Node({}, {}) _insert(node.dirs[dirname], rest, itemid) def libtree(lib: Library) -> Node: """Generates a filesystem-like directory tree for the files contained in `lib`. Filesystem nodes are (files, dirs) named tuples in which both components are dictionaries. The first maps filenames to Item ids. The second maps directory names to child node tuples. """ root = Node({}, {}) for item in lib.items(): dest = item.destination(relative_to_libdir=True) parts = util.components(util.as_string(dest)) _insert(root, parts, item.id) return root ================================================ FILE: beetsplug/absubmit.py ================================================ # This file is part of beets. # Copyright 2016, Pieter Mulder. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Calculate acoustic information and submit to AcousticBrainz.""" import errno import hashlib import json import os import shutil import subprocess import tempfile import requests from beets import plugins, ui, util # We use this field to check whether AcousticBrainz info is present. PROBE_FIELD = "mood_acoustic" class ABSubmitError(Exception): """Raised when failing to analyse file with extractor.""" def call(args): """Execute the command and return its output. Raise a AnalysisABSubmitError on failure. """ try: return util.command_output(args).stdout except subprocess.CalledProcessError as e: raise ABSubmitError(f"{args[0]} exited with status {e.returncode}") class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self._log.warning("This plugin is deprecated.") self.config.add( {"extractor": "", "force": False, "pretend": False, "base_url": ""} ) self.extractor = self.config["extractor"].as_str() if self.extractor: self.extractor = util.normpath(self.extractor) # Explicit path to extractor if not os.path.isfile(self.extractor): raise ui.UserError( f"Extractor command does not exist: {self.extractor}." ) else: # Implicit path to extractor, search for it in path self.extractor = "streaming_extractor_music" try: call([self.extractor]) except OSError: raise ui.UserError( "No extractor command found: please install the extractor" " binary from https://essentia.upf.edu/" ) except ABSubmitError: # Extractor found, will exit with an error if not called with # the correct amount of arguments. pass # Get the executable location on the system, which we need # to calculate the SHA-1 hash. self.extractor = shutil.which(self.extractor) # Calculate extractor hash. self.extractor_sha = hashlib.sha1() with open(self.extractor, "rb") as extractor: self.extractor_sha.update(extractor.read()) self.extractor_sha = self.extractor_sha.hexdigest() self.url = "" base_url = self.config["base_url"].as_str() if base_url: if not base_url.startswith("http"): raise ui.UserError( "AcousticBrainz server base URL must start " "with an HTTP scheme" ) elif base_url[-1] != "/": base_url = f"{base_url}/" self.url = f"{base_url}{{mbid}}/low-level" def commands(self): cmd = ui.Subcommand( "absubmit", help="calculate and submit AcousticBrainz analysis" ) cmd.parser.add_option( "-f", "--force", dest="force_refetch", action="store_true", default=False, help="re-download data when already present", ) cmd.parser.add_option( "-p", "--pretend", dest="pretend_fetch", action="store_true", default=False, help=( "pretend to perform action, but show only files which would be" " processed" ), ) cmd.func = self.command return [cmd] def command(self, lib, opts, args): if not self.url: raise ui.UserError( "This plugin is deprecated since AcousticBrainz no longer " "accepts new submissions. See the base_url configuration " "option." ) else: # Get items from arguments items = lib.items(args) self.opts = opts util.par_map(self.analyze_submit, items) def analyze_submit(self, item): analysis = self._get_analysis(item) if analysis: self._submit_data(item, analysis) def _get_analysis(self, item): mbid = item["mb_trackid"] # Avoid re-analyzing files that already have AB data. if not self.opts.force_refetch and not self.config["force"]: if item.get(PROBE_FIELD): return None # If file has no MBID, skip it. if not mbid: self._log.info( "Not analysing {}, missing musicbrainz track id.", item ) return None if self.opts.pretend_fetch or self.config["pretend"]: self._log.info("pretend action - extract item: {}", item) return None # Temporary file to save extractor output to, extractor only works # if an output file is given. Here we use a temporary file to copy # the data into a python object and then remove the file from the # system. tmp_file, filename = tempfile.mkstemp(suffix=".json") try: # Close the file, so the extractor can overwrite it. os.close(tmp_file) try: call([self.extractor, util.syspath(item.path), filename]) except ABSubmitError as e: self._log.warning( "Failed to analyse {item} for AcousticBrainz: {error}", item=item, error=e, ) return None with open(filename) as tmp_file: analysis = json.load(tmp_file) # Add the hash to the output. analysis["metadata"]["version"]["essentia_build_sha"] = ( self.extractor_sha ) return analysis finally: try: os.remove(filename) except OSError as e: # ENOENT means file does not exist, just ignore this error. if e.errno != errno.ENOENT: raise def _submit_data(self, item, data): mbid = item["mb_trackid"] headers = {"Content-Type": "application/json"} response = requests.post( self.url.format(mbid=mbid), json=data, headers=headers, timeout=10, ) # Test that request was successful and raise an error on failure. if response.status_code != 200: try: message = response.json()["message"] except (ValueError, KeyError) as e: message = f"unable to get error message: {e}" self._log.error( "Failed to submit AcousticBrainz analysis of {item}: " "{message}).", item=item, message=message, ) else: self._log.debug( "Successfully submitted AcousticBrainz analysis for {}.", item, ) ================================================ FILE: beetsplug/acousticbrainz.py ================================================ # This file is part of beets. # Copyright 2015-2016, Ohm Patel. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetch various AcousticBrainz metadata using MBID.""" from collections import defaultdict from typing import ClassVar import requests from beets import plugins, ui from beets.dbcore import types LEVELS = ["/low-level", "/high-level"] ABSCHEME = { "highlevel": { "danceability": {"all": {"danceable": "danceable"}}, "gender": {"value": "gender"}, "genre_rosamerica": {"value": "genre_rosamerica"}, "mood_acoustic": {"all": {"acoustic": "mood_acoustic"}}, "mood_aggressive": {"all": {"aggressive": "mood_aggressive"}}, "mood_electronic": {"all": {"electronic": "mood_electronic"}}, "mood_happy": {"all": {"happy": "mood_happy"}}, "mood_party": {"all": {"party": "mood_party"}}, "mood_relaxed": {"all": {"relaxed": "mood_relaxed"}}, "mood_sad": {"all": {"sad": "mood_sad"}}, "moods_mirex": {"value": "moods_mirex"}, "ismir04_rhythm": {"value": "rhythm"}, "tonal_atonal": {"all": {"tonal": "tonal"}}, "timbre": {"value": "timbre"}, "voice_instrumental": {"value": "voice_instrumental"}, }, "lowlevel": {"average_loudness": "average_loudness"}, "rhythm": {"bpm": "bpm"}, "tonal": { "chords_changes_rate": "chords_changes_rate", "chords_key": "chords_key", "chords_number_rate": "chords_number_rate", "chords_scale": "chords_scale", "key_key": ("initial_key", 0), "key_scale": ("initial_key", 1), "key_strength": "key_strength", }, } class AcousticPlugin(plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "average_loudness": types.Float(6), "chords_changes_rate": types.Float(6), "chords_key": types.STRING, "chords_number_rate": types.Float(6), "chords_scale": types.STRING, "danceable": types.Float(6), "gender": types.STRING, "genre_rosamerica": types.STRING, "initial_key": types.STRING, "key_strength": types.Float(6), "mood_acoustic": types.Float(6), "mood_aggressive": types.Float(6), "mood_electronic": types.Float(6), "mood_happy": types.Float(6), "mood_party": types.Float(6), "mood_relaxed": types.Float(6), "mood_sad": types.Float(6), "moods_mirex": types.STRING, "rhythm": types.Float(6), "timbre": types.STRING, "tonal": types.Float(6), "voice_instrumental": types.STRING, } def __init__(self): super().__init__() self._log.warning("This plugin is deprecated.") self.config.add( {"auto": True, "force": False, "tags": [], "base_url": ""} ) self.base_url = self.config["base_url"].as_str() if self.base_url: if not self.base_url.startswith("http"): raise ui.UserError( "AcousticBrainz server base URL must start " "with an HTTP scheme" ) elif self.base_url[-1] != "/": self.base_url = f"{self.base_url}/" if self.config["auto"]: self.register_listener("import_task_files", self.import_task_files) def commands(self): cmd = ui.Subcommand( "acousticbrainz", help="fetch metadata from AcousticBrainz" ) cmd.parser.add_option( "-f", "--force", dest="force_refetch", action="store_true", default=False, help="re-download data when already present", ) def func(lib, opts, args): items = lib.items(args) self._fetch_info( items, ui.should_write(), opts.force_refetch or self.config["force"], ) cmd.func = func return [cmd] def import_task_files(self, session, task): """Function is called upon beet import.""" self._fetch_info(task.imported_items(), False, True) def _get_data(self, mbid): if not self.base_url: raise ui.UserError( "This plugin is deprecated since AcousticBrainz has shut " "down. See the base_url configuration option." ) data = {} for url in _generate_urls(self.base_url, mbid): self._log.debug("fetching URL: {}", url) try: res = requests.get(url, timeout=10) except requests.RequestException as exc: self._log.info("request error: {}", exc) return {} if res.status_code == 404: self._log.info("recording ID {} not found", mbid) return {} try: data.update(res.json()) except ValueError: self._log.debug("Invalid Response: {.text}", res) return {} return data def _fetch_info(self, items, write, force): """Fetch additional information from AcousticBrainz for the `item`s.""" tags = self.config["tags"].as_str_seq() for item in items: # If we're not forcing re-downloading for all tracks, check # whether the data is already present. We use one # representative field name to check for previously fetched # data. if not force: mood_str = item.get("mood_acoustic", "") if mood_str: self._log.info("data already present for: {}", item) continue # We can only fetch data for tracks with MBIDs. if not item.mb_trackid: continue self._log.info("getting data for: {}", item) data = self._get_data(item.mb_trackid) if data: for attr, val in self._map_data_to_scheme(data, ABSCHEME): if not tags or attr in tags: self._log.debug( "attribute {} of {} set to {}", attr, item, val ) setattr(item, attr, val) else: self._log.debug( "skipping attribute {} of {}" " (value {}) due to config", attr, item, val, ) item.store() if write: item.try_write() def _map_data_to_scheme(self, data, scheme): """Given `data` as a structure of nested dictionaries, and `scheme` as a structure of nested dictionaries , `yield` tuples `(attr, val)` where `attr` and `val` are corresponding leaf nodes in `scheme` and `data`. As its name indicates, `scheme` defines how the data is structured, so this function tries to find leaf nodes in `data` that correspond to the leafs nodes of `scheme`, and not the other way around. Leaf nodes of `data` that do not exist in the `scheme` do not matter. If a leaf node of `scheme` is not present in `data`, no value is yielded for that attribute and a simple warning is issued. Finally, to account for attributes of which the value is split between several leaf nodes in `data`, leaf nodes of `scheme` can be tuples `(attr, order)` where `attr` is the attribute to which the leaf node belongs, and `order` is the place at which it should appear in the value. The different `value`s belonging to the same `attr` are simply joined with `' '`. This is hardcoded and not very flexible, but it gets the job done. For example: >>> scheme = { 'key1': 'attribute', 'key group': { 'subkey1': 'subattribute', 'subkey2': ('composite attribute', 0) }, 'key2': ('composite attribute', 1) } >>> data = { 'key1': 'value', 'key group': { 'subkey1': 'subvalue', 'subkey2': 'part 1 of composite attr' }, 'key2': 'part 2' } >>> print(list(_map_data_to_scheme(data, scheme))) [('subattribute', 'subvalue'), ('attribute', 'value'), ('composite attribute', 'part 1 of composite attr part 2')] """ # First, we traverse `scheme` and `data`, `yield`ing all the non # composites attributes straight away and populating the dictionary # `composites` with the composite attributes. # When we are finished traversing `scheme`, `composites` should # map each composite attribute to an ordered list of the values # belonging to the attribute, for example: # `composites = {'initial_key': ['B', 'minor']}`. # The recursive traversal. composites = defaultdict(list) yield from self._data_to_scheme_child(data, scheme, composites) # When composites has been populated, yield the composite attributes # by joining their parts. for composite_attr, value_parts in composites.items(): yield composite_attr, " ".join(value_parts) def _data_to_scheme_child(self, subdata, subscheme, composites): """The recursive business logic of :meth:`_map_data_to_scheme`: Traverse two structures of nested dictionaries in parallel and `yield` tuples of corresponding leaf nodes. If a leaf node belongs to a composite attribute (is a `tuple`), populate `composites` rather than yielding straight away. All the child functions for a single traversal share the same `composites` instance, which is passed along. """ for k, v in subscheme.items(): if k in subdata: if isinstance(v, dict): yield from self._data_to_scheme_child( subdata[k], v, composites ) elif isinstance(v, tuple): composite_attribute, part_number = v attribute_parts = composites[composite_attribute] # Parts are not guaranteed to be inserted in order while len(attribute_parts) <= part_number: attribute_parts.append("") attribute_parts[part_number] = subdata[k] else: yield v, subdata[k] else: self._log.warning( "Acousticbrainz did not provide info about {}", k ) self._log.debug( "Data {} could not be mapped to scheme {} " "because key {} was not found", subdata, v, k, ) def _generate_urls(base_url, mbid): """Generates AcousticBrainz end point urls for given `mbid`.""" for level in LEVELS: yield f"{base_url}{mbid}{level}" ================================================ FILE: beetsplug/advancedrewrite.py ================================================ # This file is part of beets. # Copyright 2023, Max Rumpf. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Plugin to rewrite fields based on a given query.""" import re import shlex from collections import defaultdict import confuse from beets.dbcore import AndQuery, query_from_strings from beets.dbcore.types import MULTI_VALUE_DSV from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import UserError def rewriter(field, simple_rules, advanced_rules): """Template field function factory. Create a template field function that rewrites the given field with the given rewriting rules. ``simple_rules`` must be a list of (pattern, replacement) pairs. ``advanced_rules`` must be a list of (query, replacement) pairs. """ def fieldfunc(item): value = item._values_fixed[field] for pattern, replacement in simple_rules: if pattern.match(value.lower()): # Rewrite activated. return replacement for query, replacement in advanced_rules: if query.match(item): # Rewrite activated. return replacement # Not activated; return original value. return value return fieldfunc class AdvancedRewritePlugin(BeetsPlugin): """Plugin to rewrite fields based on a given query.""" def __init__(self): """Parse configuration and register template fields for rewriting.""" super().__init__() self.register_listener("pluginload", self.loaded) def loaded(self): template = confuse.Sequence( confuse.OneOf( [ confuse.MappingValues(str), { "match": str, "replacements": confuse.MappingValues( confuse.OneOf([str, confuse.Sequence(str)]), ), }, ] ) ) # Used to apply the same rewrite to the corresponding album field. corresponding_album_fields = { "artist": "albumartist", "artists": "albumartists", "artist_sort": "albumartist_sort", "artists_sort": "albumartists_sort", } # Gather all the rewrite rules for each field. class RulesContainer: def __init__(self): self.simple = [] self.advanced = [] rules = defaultdict(RulesContainer) for rule in self.config.get(template): if "match" not in rule: # Simple syntax if len(rule) != 1: raise UserError( "Simple rewrites must have only one rule, " "but found multiple entries. " "Did you forget to prepend a dash (-)?" ) key, value = next(iter(rule.items())) try: fieldname, pattern = key.split(None, 1) except ValueError: raise UserError( f"Invalid simple rewrite specification {key}" ) if fieldname not in Item._fields: raise UserError( f"invalid field name {fieldname} in rewriter" ) self._log.debug( f"adding simple rewrite '{pattern}' → '{value}' " f"for field {fieldname}" ) pattern = re.compile(pattern.lower()) rules[fieldname].simple.append((pattern, value)) # Apply the same rewrite to the corresponding album field. if fieldname in corresponding_album_fields: album_fieldname = corresponding_album_fields[fieldname] rules[album_fieldname].simple.append((pattern, value)) else: # Advanced syntax match = rule["match"] replacements = rule["replacements"] if len(replacements) == 0: raise UserError( "Advanced rewrites must have at least one replacement" ) query = query_from_strings( AndQuery, Item, prefixes={}, query_parts=shlex.split(match), ) for fieldname, replacement in replacements.items(): if fieldname not in Item._fields: raise UserError( f"Invalid field name {fieldname} in rewriter" ) self._log.debug( f"adding advanced rewrite to '{replacement}' " f"for field {fieldname}" ) if isinstance(replacement, list): if Item._fields[fieldname] is not MULTI_VALUE_DSV: raise UserError( f"Field {fieldname} is not a multi-valued field " f"but a list was given: {', '.join(replacement)}" ) elif isinstance(replacement, str): if Item._fields[fieldname] is MULTI_VALUE_DSV: replacement = [replacement] else: raise UserError( f"Invalid type of replacement {replacement} " f"for field {fieldname}" ) rules[fieldname].advanced.append((query, replacement)) # Apply the same rewrite to the corresponding album field. if fieldname in corresponding_album_fields: album_fieldname = corresponding_album_fields[fieldname] rules[album_fieldname].advanced.append( (query, replacement) ) # Replace each template field with the new rewriter function. for fieldname, fieldrules in rules.items(): getter = rewriter(fieldname, fieldrules.simple, fieldrules.advanced) self.template_fields[fieldname] = getter if fieldname in Album._fields: self.album_template_fields[fieldname] = getter ================================================ FILE: beetsplug/albumtypes.py ================================================ # This file is part of beets. # Copyright 2021, Edgars Supe. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds an album template field for formatted album types.""" from __future__ import annotations from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin from .musicbrainz import VARIOUS_ARTISTS_ID if TYPE_CHECKING: from beets.library import Album class AlbumTypesPlugin(BeetsPlugin): """Adds an album template field for formatted album types.""" def __init__(self): """Init AlbumTypesPlugin.""" super().__init__() self.album_template_fields["atypes"] = self._atypes self.config.add( { "types": [ ("ep", "EP"), ("single", "Single"), ("soundtrack", "OST"), ("live", "Live"), ("compilation", "Anthology"), ("remix", "Remix"), ], "ignore_va": ["compilation"], "bracket": "[]", } ) def _atypes(self, item: Album): """Returns a formatted string based on album's types.""" types = self.config["types"].as_pairs() ignore_va = self.config["ignore_va"].as_str_seq() bracket = self.config["bracket"].as_str() # Assign a left and right bracket or leave blank if argument is empty. if len(bracket) == 2: bracket_l = bracket[0] bracket_r = bracket[1] else: bracket_l = "" bracket_r = "" res = "" albumtypes = item.albumtypes is_va = item.mb_albumartistid == VARIOUS_ARTISTS_ID for type in types: if type[0] in albumtypes and type[1]: if not is_va or (type[0] not in ignore_va and is_va): res += f"{bracket_l}{type[1]}{bracket_r}" return res ================================================ FILE: beetsplug/aura.py ================================================ # This file is part of beets. # Copyright 2020, Callum Brown. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """An AURA server using Flask.""" from __future__ import annotations import os import re from dataclasses import dataclass from mimetypes import guess_type from typing import TYPE_CHECKING, ClassVar from flask import ( Blueprint, Flask, current_app, make_response, request, send_file, ) from typing_extensions import Self from beets import config from beets.dbcore.query import ( AndQuery, FixedFieldSort, MatchQuery, MultipleSort, NotQuery, RegexpQuery, SlowFieldSort, ) from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, _open_library if TYPE_CHECKING: from collections.abc import Mapping from beets.dbcore.query import SQLiteType from beets.library import LibModel, Library # Constants # AURA server information # TODO: Add version information SERVER_INFO = { "aura-version": "0", "server": "beets-aura", "server-version": "0.1", "auth-required": False, "features": ["albums", "artists", "images"], } # Maps AURA Track attribute to beets Item attribute TRACK_ATTR_MAP = { # Required "title": "title", "artist": "artist", # Optional "album": "album", "track": "track", # Track number on album "tracktotal": "tracktotal", "disc": "disc", "disctotal": "disctotal", "year": "year", "month": "month", "day": "day", "bpm": "bpm", "genre": "genres", "genres": "genres", "recording-mbid": "mb_trackid", # beets trackid is MB recording "track-mbid": "mb_releasetrackid", "composer": "composer", "albumartist": "albumartist", "comments": "comments", # Optional for Audio Metadata # TODO: Support the mimetype attribute, format != mime type # "mimetype": track.format, "duration": "length", "framerate": "samplerate", # I don't think beets has a framecount field # "framecount": ???, "channels": "channels", "bitrate": "bitrate", "bitdepth": "bitdepth", "size": "filesize", } # Maps AURA Album attribute to beets Album attribute ALBUM_ATTR_MAP = { # Required "title": "album", "artist": "albumartist", # Optional "tracktotal": "albumtotal", "disctotal": "disctotal", "year": "year", "month": "month", "day": "day", "genre": "genres", "genres": "genres", "release-mbid": "mb_albumid", "release-group-mbid": "mb_releasegroupid", } # Maps AURA Artist attribute to beets Item field # Artists are not first-class in beets, so information is extracted from # beets Items. ARTIST_ATTR_MAP = { # Required "name": "artist", # Optional "artist-mbid": "mb_artistid", } @dataclass class AURADocument: """Base class for building AURA documents.""" model_cls: ClassVar[type[LibModel]] lib: Library args: Mapping[str, str] @classmethod def from_app(cls) -> Self: """Initialise the document using the global app and request.""" return cls(current_app.config["lib"], request.args) @staticmethod def error(status, title, detail): """Make a response for an error following the JSON:API spec. Args: status: An HTTP status code string, e.g. "404 Not Found". title: A short, human-readable summary of the problem. detail: A human-readable explanation specific to this occurrence of the problem. """ document = { "errors": [{"status": status, "title": title, "detail": detail}] } return make_response(document, status) @classmethod def get_attribute_converter(cls, beets_attr: str) -> type[SQLiteType]: """Work out what data type an attribute should be for beets. Args: beets_attr: The name of the beets attribute, e.g. "title". """ try: # Look for field in list of Album fields # and get python type of database type. # See beets.library.Album and beets.dbcore.types return cls.model_cls._fields[beets_attr].model_type except KeyError: # Fall back to string (NOTE: probably not good) return str def translate_filters(self): """Translate filters from request arguments to a beets Query.""" # The format of each filter key in the request parameter is: # filter[]. This regex extracts . pattern = re.compile(r"filter\[(?P[a-zA-Z0-9_-]+)\]") queries = [] for key, value in self.args.items(): match = pattern.match(key) if match: # Extract attribute name from key aura_attr = match.group("attribute") # Get the beets version of the attribute name beets_attr = self.attribute_map.get(aura_attr, aura_attr) converter = self.get_attribute_converter(beets_attr) value = converter(value) # Add exact match query to list # Use a slow query so it works with all fields queries.append( self.model_cls.field_query(beets_attr, value, MatchQuery) ) # NOTE: AURA doesn't officially support multiple queries return AndQuery(queries) def translate_sorts(self, sort_arg): """Translate an AURA sort parameter into a beets Sort. Args: sort_arg: The value of the 'sort' query parameter; a comma separated list of fields to sort by, in order. E.g. "-year,title". """ # Change HTTP query parameter to a list aura_sorts = sort_arg.strip(",").split(",") sorts = [] for aura_attr in aura_sorts: if aura_attr[0] == "-": ascending = False # Remove leading "-" aura_attr = aura_attr[1:] else: # JSON:API default ascending = True # Get the beets version of the attribute name beets_attr = self.attribute_map.get(aura_attr, aura_attr) # Use slow sort so it works with all fields (inc. computed) sorts.append(SlowFieldSort(beets_attr, ascending=ascending)) return MultipleSort(sorts) def paginate(self, collection): """Get a page of the collection and the URL to the next page. Args: collection: The raw data from which resource objects can be built. Could be an sqlite3.Cursor object (tracks and albums) or a list of strings (artists). """ # Pages start from zero page = self.args.get("page", 0, int) # Use page limit defined in config by default. default_limit = config["aura"]["page_limit"].get(int) limit = self.args.get("limit", default_limit, int) # start = offset of first item to return start = page * limit # end = offset of last item + 1 end = start + limit if end > len(collection): end = len(collection) next_url = None else: # Not the last page so work out links.next url if not self.args: # No existing arguments, so current page is 0 next_url = f"{request.url}?page=1" elif not self.args.get("page", None): # No existing page argument, so add one to the end next_url = f"{request.url}&page=1" else: # Increment page token by 1 next_url = request.url.replace( f"page={page}", f"page={page + 1}" ) # Get only the items in the page range data = [ self.get_resource_object(self.lib, collection[i]) for i in range(start, end) ] return data, next_url def get_included(self, data, include_str): """Build a list of resource objects for inclusion. Args: data: An array of dicts in the form of resource objects. include_str: A comma separated list of resource types to include. E.g. "tracks,images". """ # Change HTTP query parameter to a list to_include = include_str.strip(",").split(",") # Build a list of unique type and id combinations # For each resource object in the primary data, iterate over it's # relationships. If a relationship matches one of the types # requested for inclusion (e.g. "albums") then add each type-id pair # under the "data" key to unique_identifiers, checking first that # it has not already been added. This ensures that no resources are # included more than once. unique_identifiers = [] for res_obj in data: for rel_name, rel_obj in res_obj["relationships"].items(): if rel_name in to_include: # NOTE: Assumes relationship is to-many for identifier in rel_obj["data"]: if identifier not in unique_identifiers: unique_identifiers.append(identifier) # TODO: I think this could be improved included = [] for identifier in unique_identifiers: res_type = identifier["type"] if res_type == "track": track_id = int(identifier["id"]) track = self.lib.get_item(track_id) included.append( TrackDocument.get_resource_object(self.lib, track) ) elif res_type == "album": album_id = int(identifier["id"]) album = self.lib.get_album(album_id) included.append( AlbumDocument.get_resource_object(self.lib, album) ) elif res_type == "artist": artist_id = identifier["id"] included.append( ArtistDocument.get_resource_object(self.lib, artist_id) ) elif res_type == "image": image_id = identifier["id"] included.append( ImageDocument.get_resource_object(self.lib, image_id) ) else: raise ValueError(f"Invalid resource type: {res_type}") return included def all_resources(self): """Build document for /tracks, /albums or /artists.""" query = self.translate_filters() sort_arg = self.args.get("sort", None) if sort_arg: sort = self.translate_sorts(sort_arg) # For each sort field add a query which ensures all results # have a non-empty, non-zero value for that field. query.subqueries.extend( NotQuery( self.model_cls.field_query(s.field, "(^$|^0$)", RegexpQuery) ) for s in sort.sorts ) else: sort = None # Get information from the library collection = self.get_collection(query=query, sort=sort) # Convert info to AURA form and paginate it data, next_url = self.paginate(collection) document = {"data": data} # If there are more pages then provide a way to access them if next_url: document["links"] = {"next": next_url} # Include related resources for each element in "data" include_str = self.args.get("include", None) if include_str: document["included"] = self.get_included(data, include_str) return document def single_resource_document(self, resource_object): """Build document for a specific requested resource. Args: resource_object: A dictionary in the form of a JSON:API resource object. """ document = {"data": resource_object} include_str = self.args.get("include", None) if include_str: # [document["data"]] is because arg needs to be list document["included"] = self.get_included( [document["data"]], include_str ) return document class TrackDocument(AURADocument): """Class for building documents for /tracks endpoints.""" model_cls = Item attribute_map = TRACK_ATTR_MAP def get_collection(self, query=None, sort=None): """Get Item objects from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ return self.lib.items(query, sort) @classmethod def get_attribute_converter(cls, beets_attr: str) -> type[SQLiteType]: """Work out what data type an attribute should be for beets. Args: beets_attr: The name of the beets attribute, e.g. "title". """ # filesize is a special field (read from disk not db?) if beets_attr == "filesize": return int return super().get_attribute_converter(beets_attr) @staticmethod def get_resource_object(lib: Library, track): """Construct a JSON:API resource object from a beets Item. Args: track: A beets Item object. """ attributes = {} # Use aura => beets attribute map, e.g. size => filesize for aura_attr, beets_attr in TRACK_ATTR_MAP.items(): a = getattr(track, beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could result in required attributes not being set if a: attributes[aura_attr] = a # JSON:API one-to-many relationship to parent album relationships = { "artists": {"data": [{"type": "artist", "id": track.artist}]} } # Only add album relationship if not singleton if not track.singleton: relationships["albums"] = { "data": [{"type": "album", "id": str(track.album_id)}] } return { "type": "track", "id": str(track.id), "attributes": attributes, "relationships": relationships, } def single_resource(self, track_id): """Get track from the library and build a document. Args: track_id: The beets id of the track (integer). """ track = self.lib.get_item(track_id) if not track: return self.error( "404 Not Found", "No track with the requested id.", f"There is no track with an id of {track_id} in the library.", ) return self.single_resource_document( self.get_resource_object(self.lib, track) ) class AlbumDocument(AURADocument): """Class for building documents for /albums endpoints.""" model_cls = Album attribute_map = ALBUM_ATTR_MAP def get_collection(self, query=None, sort=None): """Get Album objects from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ return self.lib.albums(query, sort) @staticmethod def get_resource_object(lib: Library, album): """Construct a JSON:API resource object from a beets Album. Args: album: A beets Album object. """ attributes = {} # Use aura => beets attribute name map for aura_attr, beets_attr in ALBUM_ATTR_MAP.items(): a = getattr(album, beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could mean required attributes are not set if a: attributes[aura_attr] = a # Get beets Item objects for all tracks in the album sorted by # track number. Sorting is not required but it's nice. query = MatchQuery("album_id", album.id) sort = FixedFieldSort("track", ascending=True) tracks = lib.items(query, sort) # JSON:API one-to-many relationship to tracks on the album relationships = { "tracks": { "data": [{"type": "track", "id": str(t.id)} for t in tracks] } } # Add images relationship if album has associated images if album.artpath: path = os.fsdecode(album.artpath) filename = path.split("/")[-1] image_id = f"album-{album.id}-{filename}" relationships["images"] = { "data": [{"type": "image", "id": image_id}] } # Add artist relationship if artist name is same on tracks # Tracks are used to define artists so don't albumartist # Check for all tracks in case some have featured artists if album.albumartist in [t.artist for t in tracks]: relationships["artists"] = { "data": [{"type": "artist", "id": album.albumartist}] } return { "type": "album", "id": str(album.id), "attributes": attributes, "relationships": relationships, } def single_resource(self, album_id): """Get album from the library and build a document. Args: album_id: The beets id of the album (integer). """ album = self.lib.get_album(album_id) if not album: return self.error( "404 Not Found", "No album with the requested id.", f"There is no album with an id of {album_id} in the library.", ) return self.single_resource_document( self.get_resource_object(self.lib, album) ) class ArtistDocument(AURADocument): """Class for building documents for /artists endpoints.""" model_cls = Item attribute_map = ARTIST_ATTR_MAP def get_collection(self, query=None, sort=None): """Get a list of artist names from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ # Gets only tracks with matching artist information tracks = self.lib.items(query, sort) collection = [] for track in tracks: # Do not add duplicates if track.artist not in collection: collection.append(track.artist) return collection @staticmethod def get_resource_object(lib: Library, artist_id): """Construct a JSON:API resource object for the given artist. Args: artist_id: A string which is the artist's name. """ # Get tracks where artist field exactly matches artist_id query = MatchQuery("artist", artist_id) tracks = lib.items(query) if not tracks: return None # Get artist information from the first track # NOTE: It could be that the first track doesn't have a # MusicBrainz id but later tracks do, which isn't ideal. attributes = {} # Use aura => beets attribute map, e.g. artist => name for aura_attr, beets_attr in ARTIST_ATTR_MAP.items(): a = getattr(tracks[0], beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could mean required attributes are not set if a: attributes[aura_attr] = a relationships = { "tracks": { "data": [{"type": "track", "id": str(t.id)} for t in tracks] } } album_query = MatchQuery("albumartist", artist_id) albums = lib.albums(query=album_query) if len(albums) != 0: relationships["albums"] = { "data": [{"type": "album", "id": str(a.id)} for a in albums] } return { "type": "artist", "id": artist_id, "attributes": attributes, "relationships": relationships, } def single_resource(self, artist_id): """Get info for the requested artist and build a document. Args: artist_id: A string which is the artist's name. """ artist_resource = self.get_resource_object(self.lib, artist_id) if not artist_resource: return self.error( "404 Not Found", "No artist with the requested id.", f"There is no artist with an id of {artist_id} in the library.", ) return self.single_resource_document(artist_resource) def safe_filename(fn): """Check whether a string is a simple (non-path) filename. For example, `foo.txt` is safe because it is a "plain" filename. But `foo/bar.txt` and `../foo.txt` and `.` are all non-safe because they can traverse to other directories other than the current one. """ # Rule out any directories. if os.path.basename(fn) != fn: return False # In single names, rule out Unix directory traversal names. if fn in (".", ".."): return False return True class ImageDocument(AURADocument): """Class for building documents for /images/(id) endpoints.""" model_cls = Album @staticmethod def get_image_path(lib: Library, image_id): """Works out the full path to the image with the given id. Returns None if there is no such image. Args: image_id: A string in the form "--". """ # Split image_id into its constituent parts id_split = image_id.split("-") if len(id_split) < 3: # image_id is not in the required format return None parent_type = id_split[0] parent_id = id_split[1] img_filename = "-".join(id_split[2:]) if not safe_filename(img_filename): return None # Get the path to the directory parent's images are in if parent_type == "album": album = lib.get_album(int(parent_id)) if not album or not album.artpath: return None # Cut the filename off of artpath # This is in preparation for supporting images in the same # directory that are not tracked by beets. artpath = os.fsdecode(album.artpath) dir_path = "/".join(artpath.split("/")[:-1]) else: # Images for other resource types are not supported return None img_path = os.path.join(dir_path, img_filename) # Check the image actually exists if os.path.isfile(img_path): return img_path else: return None @staticmethod def get_resource_object(lib: Library, image_id): """Construct a JSON:API resource object for the given image. Args: image_id: A string in the form "--". """ # Could be called as a static method, so can't use # self.get_image_path() image_path = ImageDocument.get_image_path(lib, image_id) if not image_path: return None attributes = { "role": "cover", "mimetype": guess_type(image_path)[0], "size": os.path.getsize(image_path), } try: from PIL import Image except ImportError: pass else: im = Image.open(image_path) attributes["width"] = im.width attributes["height"] = im.height relationships = {} # Split id into [parent_type, parent_id, filename] id_split = image_id.split("-") relationships[f"{id_split[0]}s"] = { "data": [{"type": id_split[0], "id": id_split[1]}] } return { "id": image_id, "type": "image", # Remove attributes that are None, 0, "", etc. "attributes": {k: v for k, v in attributes.items() if v}, "relationships": relationships, } def single_resource(self, image_id): """Get info for the requested image and build a document. Args: image_id: A string in the form "--". """ image_resource = self.get_resource_object(self.lib, image_id) if not image_resource: return self.error( "404 Not Found", "No image with the requested id.", f"There is no image with an id of {image_id} in the library.", ) return self.single_resource_document(image_resource) # Initialise flask blueprint aura_bp = Blueprint("aura_bp", __name__) @aura_bp.route("/server") def server_info(): """Respond with info about the server.""" return {"data": {"type": "server", "id": "0", "attributes": SERVER_INFO}} # Track endpoints @aura_bp.route("/tracks") def all_tracks(): """Respond with a list of all tracks and related information.""" return TrackDocument.from_app().all_resources() @aura_bp.route("/tracks/") def single_track(track_id): """Respond with info about the specified track. Args: track_id: The id of the track provided in the URL (integer). """ return TrackDocument.from_app().single_resource(track_id) @aura_bp.route("/tracks//audio") def audio_file(track_id): """Supply an audio file for the specified track. Args: track_id: The id of the track provided in the URL (integer). """ track = current_app.config["lib"].get_item(track_id) if not track: return AURADocument.error( "404 Not Found", "No track with the requested id.", f"There is no track with an id of {track_id} in the library.", ) path = os.fsdecode(track.path) if not os.path.isfile(path): return AURADocument.error( "404 Not Found", "No audio file for the requested track.", f"There is no audio file for track {track_id} at the expected" " location", ) file_mimetype = guess_type(path)[0] if not file_mimetype: return AURADocument.error( "500 Internal Server Error", "Requested audio file has an unknown mimetype.", f"The audio file for track {track_id} has an unknown mimetype. " f"Its file extension is {path.split('.')[-1]}.", ) # Check that the Accept header contains the file's mimetype # Takes into account */* and audio/* # Adding support for the bitrate parameter would require some effort so I # left it out. This means the client could be sent an error even if the # audio doesn't need transcoding. if not request.accept_mimetypes.best_match([file_mimetype]): return AURADocument.error( "406 Not Acceptable", "Unsupported MIME type or bitrate parameter in Accept header.", f"The audio file for track {track_id} is only available as" f" {file_mimetype} and bitrate parameters are not supported.", ) return send_file( path, mimetype=file_mimetype, # Handles filename in Content-Disposition header as_attachment=True, # Tries to upgrade the stream to support range requests conditional=True, ) # Album endpoints @aura_bp.route("/albums") def all_albums(): """Respond with a list of all albums and related information.""" return AlbumDocument.from_app().all_resources() @aura_bp.route("/albums/") def single_album(album_id): """Respond with info about the specified album. Args: album_id: The id of the album provided in the URL (integer). """ return AlbumDocument.from_app().single_resource(album_id) # Artist endpoints # Artist ids are their names @aura_bp.route("/artists") def all_artists(): """Respond with a list of all artists and related information.""" return ArtistDocument.from_app().all_resources() # Using the path converter allows slashes in artist_id @aura_bp.route("/artists/") def single_artist(artist_id): """Respond with info about the specified artist. Args: artist_id: The id of the artist provided in the URL. A string which is the artist's name. """ return ArtistDocument.from_app().single_resource(artist_id) # Image endpoints # Image ids are in the form -- # For example: album-13-cover.jpg @aura_bp.route("/images/") def single_image(image_id): """Respond with info about the specified image. Args: image_id: The id of the image provided in the URL. A string in the form "--". """ return ImageDocument.from_app().single_resource(image_id) @aura_bp.route("/images//file") def image_file(image_id): """Supply an image file for the specified image. Args: image_id: The id of the image provided in the URL. A string in the form "--". """ img_path = ImageDocument.get_image_path(current_app.config["lib"], image_id) if not img_path: return AURADocument.error( "404 Not Found", "No image with the requested id.", f"There is no image with an id of {image_id} in the library", ) return send_file(img_path) # WSGI app def create_app(): """An application factory for use by a WSGI server.""" config["aura"].add( { "host": "127.0.0.1", "port": 8337, "cors": [], "cors_supports_credentials": False, "page_limit": 500, } ) app = Flask(__name__) # Register AURA blueprint view functions under a URL prefix app.register_blueprint(aura_bp, url_prefix="/aura") # AURA specifies mimetype MUST be this app.config["JSONIFY_MIMETYPE"] = "application/vnd.api+json" # Disable auto-sorting of JSON keys app.config["JSON_SORT_KEYS"] = False # Provide a way to access the beets library # The normal method of using the Library and config provided in the # command function is not used because create_app() could be called # by an external WSGI server. # NOTE: this uses a 'private' function from beets.ui.__init__ app.config["lib"] = _open_library(config) # Enable CORS if required cors = config["aura"]["cors"].as_str_seq(list) if cors: from flask_cors import CORS # "Accept" is the only header clients use app.config["CORS_ALLOW_HEADERS"] = "Accept" app.config["CORS_RESOURCES"] = {r"/aura/*": {"origins": cors}} app.config["CORS_SUPPORTS_CREDENTIALS"] = config["aura"][ "cors_supports_credentials" ].get(bool) CORS(app) return app # Beets Plugin Hook class AURAPlugin(BeetsPlugin): """The BeetsPlugin subclass for the AURA server plugin.""" def __init__(self): """Add configuration options for the AURA plugin.""" super().__init__() def commands(self): """Add subcommand used to run the AURA server.""" def run_aura(lib, opts, args): """Run the application using Flask's built in-server. Args: lib: A beets Library object (not used). opts: Command line options. An optparse.Values object. args: The list of arguments to process (not used). """ app = create_app() # Start the built-in server (not intended for production) app.run( host=self.config["host"].get(str), port=self.config["port"].get(int), debug=opts.debug, threaded=True, ) run_aura_cmd = Subcommand("aura", help="run an AURA server") run_aura_cmd.parser.add_option( "-d", "--debug", action="store_true", default=False, help="use Flask debug mode", ) run_aura_cmd.func = run_aura return [run_aura_cmd] ================================================ FILE: beetsplug/autobpm.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Uses Librosa to calculate the `bpm` field.""" from __future__ import annotations from typing import TYPE_CHECKING import librosa import numpy as np from beets.plugins import BeetsPlugin from beets.ui import Subcommand, should_write if TYPE_CHECKING: from beets.importer import ImportTask from beets.library import Item, Library class AutoBPMPlugin(BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "auto": True, "overwrite": False, "beat_track_kwargs": {}, } ) if self.config["auto"]: self.import_stages = [self.imported] def commands(self) -> list[Subcommand]: cmd = Subcommand( "autobpm", help="detect and add bpm from audio using Librosa" ) cmd.func = self.command return [cmd] def command(self, lib: Library, _, args: list[str]) -> None: self.calculate_bpm(list(lib.items(args)), write=should_write()) def imported(self, _, task: ImportTask) -> None: self.calculate_bpm(task.imported_items()) def calculate_bpm(self, items: list[Item], write: bool = False) -> None: for item in items: path = item.filepath if bpm := item.bpm: self._log.info("BPM for {} already exists: {}", path, bpm) if not self.config["overwrite"]: continue try: y, sr = librosa.load(item.filepath, res_type="kaiser_fast") except Exception as exc: self._log.error("Failed to load {}: {}", path, exc) continue kwargs = self.config["beat_track_kwargs"].flatten() try: tempo, _ = librosa.beat.beat_track(y=y, sr=sr, **kwargs) except Exception as exc: self._log.error("Failed to measure BPM for {}: {}", path, exc) continue bpm = round( float(tempo[0] if isinstance(tempo, np.ndarray) else tempo) ) item["bpm"] = bpm self._log.info("Computed BPM for {}: {}", path, bpm) if write: item.try_write() item.store() ================================================ FILE: beetsplug/badfiles.py ================================================ # This file is part of beets. # Copyright 2016, François-Xavier Thomas. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Use command-line tools to check for audio file corruption.""" import errno import os import shlex import sys from subprocess import STDOUT, CalledProcessError, check_output, list2cmdline import confuse from beets import importer, ui from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import displayable_path, par_map from beets.util.color import colorize class CheckerCommandError(Exception): """Raised when running a checker failed. Attributes: checker: Checker command name. path: Path to the file being validated. errno: Error number from the checker execution error. msg: Message from the checker execution error. """ def __init__(self, cmd, oserror): self.checker = cmd[0] self.path = cmd[-1] self.errno = oserror.errno self.msg = str(oserror) class BadFiles(BeetsPlugin): def __init__(self): super().__init__() self.verbose = False self.register_listener("import_task_start", self.on_import_task_start) self.register_listener( "import_task_before_choice", self.on_import_task_before_choice ) def run_command(self, cmd): self._log.debug( "running command: {}", displayable_path(list2cmdline(cmd)) ) try: output = check_output(cmd, stderr=STDOUT) errors = 0 status = 0 except CalledProcessError as e: output = e.output errors = 1 status = e.returncode except OSError as e: raise CheckerCommandError(cmd, e) output = output.decode(sys.getdefaultencoding(), "replace") return status, errors, [line for line in output.split("\n") if line] def check_mp3val(self, path): status, errors, output = self.run_command(["mp3val", path]) if status == 0: output = [line for line in output if line.startswith("WARNING:")] errors = len(output) return status, errors, output def check_flac(self, path): return self.run_command(["flac", "-wst", path]) def check_custom(self, command): def checker(path): cmd = shlex.split(command) cmd.append(path) return self.run_command(cmd) return checker def get_checker(self, ext): ext = ext.lower() try: command = self.config["commands"].get(dict).get(ext) except confuse.NotFoundError: command = None if command: return self.check_custom(command) if ext == "mp3": return self.check_mp3val if ext == "flac": return self.check_flac def check_item(self, item): # First, check whether the path exists. If not, the user # should probably run `beet update` to cleanup your library. dpath = displayable_path(item.path) self._log.debug("checking path: {}", dpath) if not os.path.exists(item.path): ui.print_(f"{colorize('text_error', dpath)}: file does not exist") # Run the checker against the file if one is found ext = os.path.splitext(item.path)[1][1:].decode("utf8", "ignore") checker = self.get_checker(ext) if not checker: self._log.error("no checker specified in the config for {}", ext) return [] path = item.path if not isinstance(path, str): path = item.path.decode(sys.getfilesystemencoding()) try: status, errors, output = checker(path) except CheckerCommandError as e: if e.errno == errno.ENOENT: self._log.error( "command not found: {0.checker} when validating file: {0.path}", e, ) else: self._log.error("error invoking {0.checker}: {0.msg}", e) return [] error_lines = [] if status > 0: error_lines.append( f"{colorize('text_error', dpath)}: checker exited with status {status}" ) for line in output: error_lines.append(f" {line}") elif errors > 0: error_lines.append( f"{colorize('text_warning', dpath)}: checker found" f" {errors} errors or warnings" ) for line in output: error_lines.append(f" {line}") elif self.verbose: error_lines.append(f"{colorize('text_success', dpath)}: ok") return error_lines def on_import_task_start(self, task, session): if not self.config["check_on_import"].get(False): return checks_failed = [] for item in task.items: error_lines = self.check_item(item) if error_lines: checks_failed.append(error_lines) if checks_failed: task._badfiles_checks_failed = checks_failed def on_import_task_before_choice(self, task, session): if hasattr(task, "_badfiles_checks_failed"): ui.print_( f"{colorize('text_warning', 'BAD')} one or more files failed checks:" ) for error in task._badfiles_checks_failed: for error_line in error: ui.print_(error_line) ui.print_() ui.print_("What would you like to do?") sel = ui.input_options(["aBort", "skip", "continue"]) if sel == "s": return importer.Action.SKIP elif sel == "c": return None elif sel == "b": raise importer.ImportAbortError() else: raise Exception(f"Unexpected selection: {sel}") def command(self, lib, opts, args): # Get items from arguments items = lib.items(args) self.verbose = opts.verbose def check_and_print(item): for error_line in self.check_item(item): ui.print_(error_line) par_map(check_and_print, items) def commands(self): bad_command = Subcommand( "bad", help="check for corrupt or missing files" ) bad_command.parser.add_option( "-v", "--verbose", action="store_true", default=False, dest="verbose", help="view results for both the bad and uncorrupted files", ) bad_command.func = self.command return [bad_command] ================================================ FILE: beetsplug/bareasc.py ================================================ # This file is part of beets. # Copyright 2016, Philippe Mongeau. # Copyright 2021, Graham R. Cobb. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and ascociated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # This module is adapted from Fuzzy in accordance to the licence of # that module """Provides a bare-ASCII matching query.""" from unidecode import unidecode from beets import ui from beets.dbcore.query import StringFieldQuery from beets.plugins import BeetsPlugin from beets.ui import print_ class BareascQuery(StringFieldQuery[str]): """Compare items using bare ASCII, without accents etc.""" @classmethod def string_match(cls, pattern, val): """Convert both pattern and string to plain ASCII before matching. If pattern is all lower case, also convert string to lower case so match is also case insensitive """ # smartcase if pattern.islower(): val = val.lower() pattern = unidecode(pattern) val = unidecode(val) return pattern in val def col_clause(self): """Compare ascii version of the pattern.""" clause = f"unidecode({self.field})" if self.pattern.islower(): clause = f"lower({clause})" return rf"{clause} LIKE ? ESCAPE '\'", [f"%{unidecode(self.pattern)}%"] class BareascPlugin(BeetsPlugin): """Plugin to provide bare-ASCII option for beets matching.""" def __init__(self): """Default prefix for selecting bare-ASCII matching is #.""" super().__init__() self.config.add( { "prefix": "#", } ) def queries(self): """Register bare-ASCII matching.""" prefix = self.config["prefix"].as_str() return {prefix: BareascQuery} def commands(self): """Add bareasc command as unidecode version of 'list'.""" cmd = ui.Subcommand( "bareasc", help="unidecode version of beet list command" ) cmd.parser.usage += ( "\nExample: %prog -f '$album: $title' artist:beatles" ) cmd.parser.add_all_common_options() cmd.func = self.unidecode_list return [cmd] def unidecode_list(self, lib, opts, args): """Emulate normal 'list' command but with unidecode output.""" album = opts.album # Copied from commands.py - list_items if album: for album in lib.albums(args): bare = unidecode(str(album)) print_(bare) else: for item in lib.items(args): bare = unidecode(str(item)) print_(bare) ================================================ FILE: beetsplug/beatport.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Beatport release and track search support to the autotagger""" from __future__ import annotations import json import re from datetime import datetime, timedelta from typing import TYPE_CHECKING, Literal, overload import confuse from requests_oauthlib import OAuth1Session from requests_oauthlib.oauth1_session import ( TokenMissing, TokenRequestDenied, VerifierMissing, ) import beets import beets.ui from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import MetadataSourcePlugin from beets.util import unique_list from beets.util.deprecation import deprecate_for_user if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence from beets.importer import ImportSession from beets.library import Item from ._typing import JSONDict AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing) USER_AGENT = f"beets/{beets.__version__} +https://beets.io/" class BeatportAPIError(Exception): pass class BeatportClient: _api_base = "https://oauth-api.beatport.com" def __init__(self, c_key, c_secret, auth_key=None, auth_secret=None): """Initiate the client with OAuth information. For the initial authentication with the backend `auth_key` and `auth_secret` can be `None`. Use `get_authorize_url` and `get_access_token` to obtain them for subsequent uses of the API. :param c_key: OAuth1 client key :param c_secret: OAuth1 client secret :param auth_key: OAuth1 resource owner key :param auth_secret: OAuth1 resource owner secret """ self.api = OAuth1Session( client_key=c_key, client_secret=c_secret, resource_owner_key=auth_key, resource_owner_secret=auth_secret, callback_uri="oob", ) self.api.headers = {"User-Agent": USER_AGENT} def get_authorize_url(self) -> str: """Generate the URL for the user to authorize the application. Retrieves a request token from the Beatport API and returns the corresponding authorization URL on their end that the user has to visit. This is the first step of the initial authorization process with the API. Once the user has visited the URL, call :py:method:`get_access_token` with the displayed data to complete the process. :returns: Authorization URL for the user to visit :rtype: unicode """ self.api.fetch_request_token( self._make_url("/identity/1/oauth/request-token") ) return self.api.authorization_url( self._make_url("/identity/1/oauth/authorize") ) def get_access_token(self, auth_data: str) -> tuple[str, str]: """Obtain the final access token and secret for the API. :param auth_data: URL-encoded authorization data as displayed at the authorization url (obtained via :py:meth:`get_authorize_url`) after signing in :returns: OAuth resource owner key and secret as unicode """ self.api.parse_authorization_response( f"https://beets.io/auth?{auth_data}" ) access_data = self.api.fetch_access_token( self._make_url("/identity/1/oauth/access-token") ) return access_data["oauth_token"], access_data["oauth_token_secret"] @overload def search( self, query: str, release_type: Literal["release"], details: bool = True, ) -> Iterator[BeatportRelease]: ... @overload def search( self, query: str, release_type: Literal["track"], details: bool = True, ) -> Iterator[BeatportTrack]: ... def search( self, query: str, release_type: Literal["release", "track"], details=True, ) -> Iterator[BeatportRelease | BeatportTrack]: """Perform a search of the Beatport catalogue. :param query: Query string :param release_type: Type of releases to search for. :param details: Retrieve additional information about the search results. Currently this will fetch the tracklist for releases and do nothing for tracks :returns: Search results """ response = self._get( "catalog/3/search", query=query, perPage=5, facets=[f"fieldType:{release_type}"], ) for item in response: if release_type == "release": release = BeatportRelease(item) if details: release.tracks = self.get_release_tracks(item["id"]) yield release elif release_type == "track": yield BeatportTrack(item) def get_release(self, beatport_id: str) -> BeatportRelease | None: """Get information about a single release. :param beatport_id: Beatport ID of the release :returns: The matching release """ response = self._get("/catalog/3/releases", id=beatport_id) if response: release = BeatportRelease(response[0]) release.tracks = self.get_release_tracks(beatport_id) return release return None def get_release_tracks(self, beatport_id: str) -> list[BeatportTrack]: """Get all tracks for a given release. :param beatport_id: Beatport ID of the release :returns: Tracks in the matching release """ response = self._get( "/catalog/3/tracks", releaseId=beatport_id, perPage=100 ) return [BeatportTrack(t) for t in response] def get_track(self, beatport_id: str) -> BeatportTrack: """Get information about a single track. :param beatport_id: Beatport ID of the track :returns: The matching track """ response = self._get("/catalog/3/tracks", id=beatport_id) return BeatportTrack(response[0]) def _make_url(self, endpoint: str) -> str: """Get complete URL for a given API endpoint.""" if not endpoint.startswith("/"): endpoint = f"/{endpoint}" return f"{self._api_base}{endpoint}" def _get(self, endpoint: str, **kwargs) -> list[JSONDict]: """Perform a GET request on a given API endpoint. Automatically extracts result data from the response and converts HTTP exceptions into :py:class:`BeatportAPIError` objects. """ try: response = self.api.get(self._make_url(endpoint), params=kwargs) except Exception as e: raise BeatportAPIError(f"Error connecting to Beatport API: {e}") if not response: raise BeatportAPIError( f"Error {response.status_code} for '{response.request.path_url}" ) return response.json()["results"] class BeatportObject: beatport_id: str name: str release_date: datetime | None = None artists: list[tuple[str, str]] | None = None # tuple of artist id and artist name def __init__(self, data: JSONDict): self.beatport_id = str(data["id"]) # given as int in the response self.name = str(data["name"]) if "releaseDate" in data: self.release_date = datetime.strptime( data["releaseDate"], "%Y-%m-%d" ) if "artists" in data: self.artists = [(x["id"], str(x["name"])) for x in data["artists"]] self.genres = unique_list( x["name"] for x in (*data.get("subGenres", []), *data.get("genres", [])) ) def artists_str(self) -> str | None: if self.artists is not None: if len(self.artists) < 4: artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" else: artist_str = None return artist_str class BeatportRelease(BeatportObject): catalog_number: str | None label_name: str | None category: str | None url: str | None tracks: list[BeatportTrack] | None = None def __init__(self, data: JSONDict): super().__init__(data) self.catalog_number = data.get("catalogNumber") self.label_name = data.get("label", {}).get("name") self.category = data.get("category") if "slug" in data: self.url = ( f"https://beatport.com/release/{data['slug']}/{data['id']}" ) def __str__(self) -> str: return ( "" ) class BeatportTrack(BeatportObject): title: str | None mix_name: str | None length: timedelta url: str | None track_number: int | None bpm: str | None initial_key: str | None def __init__(self, data: JSONDict): super().__init__(data) if "title" in data: self.title = str(data["title"]) if "mixName" in data: self.mix_name = str(data["mixName"]) self.length = timedelta(milliseconds=data.get("lengthMs", 0) or 0) if not self.length: try: min, sec = data.get("length", "0:0").split(":") self.length = timedelta(minutes=int(min), seconds=int(sec)) except ValueError: pass if "slug" in data: self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}" self.track_number = data.get("trackNumber") self.bpm = data.get("bpm") self.initial_key = str((data.get("key") or {}).get("shortName")) class BeatportPlugin(MetadataSourcePlugin): _client: BeatportClient | None = None def __init__(self): super().__init__() deprecate_for_user(self._log, "The 'beatport' plugin") self.config.add( { "apikey": "57713c3906af6f5def151b33601389176b37b429", "apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954", "tokenfile": "beatport_token.json", } ) self.config["apikey"].redact = True self.config["apisecret"].redact = True self.register_listener("import_begin", self.setup) @property def client(self) -> BeatportClient: if self._client is None: raise ValueError( "Beatport client not initialized. Call setup() first." ) return self._client def setup(self, session: ImportSession): c_key: str = self.config["apikey"].as_str() c_secret: str = self.config["apisecret"].as_str() # Get the OAuth token from a file or log in. try: with open(self._tokenfile()) as f: tokendata = json.load(f) except OSError: # No token yet. Generate one. token, secret = self.authenticate(c_key, c_secret) else: token = tokendata["token"] secret = tokendata["secret"] self._client = BeatportClient(c_key, c_secret, token, secret) def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]: # Get the link for the OAuth page. auth_client = BeatportClient(c_key, c_secret) try: url = auth_client.get_authorize_url() except AUTH_ERRORS as e: self._log.debug("authentication error: {}", e) raise beets.ui.UserError("communication with Beatport failed") beets.ui.print_("To authenticate with Beatport, visit:") beets.ui.print_(url) # Ask for the verifier data and validate it. data = beets.ui.input_("Enter the string displayed in your browser:") try: token, secret = auth_client.get_access_token(data) except AUTH_ERRORS as e: self._log.debug("authentication error: {}", e) raise beets.ui.UserError("Beatport token request failed") # Save the token for later use. self._log.debug("Beatport token {}, secret {}", token, secret) with open(self._tokenfile(), "w") as f: json.dump({"token": token, "secret": secret}, f) return token, secret def _tokenfile(self) -> str: """Get the path to the JSON file for storing the OAuth token.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool, ) -> Iterator[AlbumInfo]: if va_likely: query = album else: query = f"{artist} {album}" try: yield from self._get_releases(query) except BeatportAPIError as e: self._log.debug("API Error: {} (query: {})", e, query) return def item_candidates( self, item: Item, artist: str, title: str ) -> Iterable[TrackInfo]: query = f"{artist} {title}" try: return self._get_tracks(query) except BeatportAPIError as e: self._log.debug("API Error: {} (query: {})", e, query) return [] def album_for_id(self, album_id: str): """Fetches a release by its Beatport ID and returns an AlbumInfo object or None if the query is not a valid ID or release is not found. """ self._log.debug("Searching for release {}", album_id) if not (release_id := self._extract_id(album_id)): self._log.debug("Not a valid Beatport release ID.") return None release = self.client.get_release(release_id) if release: return self._get_album_info(release) return None def track_for_id(self, track_id: str): """Fetches a track by its Beatport ID and returns a TrackInfo object or None if the track is not a valid Beatport ID or track is not found. """ self._log.debug("Searching for track {}", track_id) # TODO: move to extractor match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id) if not match: self._log.debug("Not a valid Beatport track ID.") return None bp_track = self.client.get_track(match.group(2)) if bp_track is not None: return self._get_track_info(bp_track) return None def _get_releases(self, query: str) -> Iterator[AlbumInfo]: """Returns a list of AlbumInfo objects for a beatport search query.""" # Strip non-word characters from query. Things like "!" and "-" can # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. query = re.sub(r"\W+", " ", query, flags=re.UNICODE) # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(r"\b(CD|disc)\s*\d+", "", query, flags=re.I) for beatport_release in self.client.search(query, "release"): if beatport_release is None: continue yield self._get_album_info(beatport_release) def _get_album_info(self, release: BeatportRelease) -> AlbumInfo: """Returns an AlbumInfo object for a Beatport Release object.""" va = release.artists is not None and len(release.artists) > 3 artist, artist_id = self._get_artist(release.artists) if va: artist = config["va_name"].as_str() tracks: list[TrackInfo] = [] if release.tracks is not None: tracks = [self._get_track_info(x) for x in release.tracks] release_date = release.release_date return AlbumInfo( album=release.name, album_id=release.beatport_id, beatport_album_id=release.beatport_id, artist=artist, artist_id=artist_id, tracks=tracks, albumtype=release.category, va=va, label=release.label_name, catalognum=release.catalog_number, media="Digital", data_source=self.data_source, data_url=release.url, genres=release.genres, year=release_date.year if release_date else None, month=release_date.month if release_date else None, day=release_date.day if release_date else None, ) def _get_track_info(self, track: BeatportTrack) -> TrackInfo: """Returns a TrackInfo object for a Beatport Track object.""" title = track.name if track.mix_name != "Original Mix": title += f" ({track.mix_name})" artist, artist_id = self._get_artist(track.artists) length = track.length.total_seconds() return TrackInfo( title=title, track_id=track.beatport_id, artist=artist, artist_id=artist_id, length=length, index=track.track_number, medium_index=track.track_number, data_source=self.data_source, data_url=track.url, bpm=track.bpm, initial_key=track.initial_key, genres=track.genres, ) def _get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of Beatport release or track artists. """ return self.get_artist(artists=artists, id_key=0, name_key=1) def _get_tracks(self, query): """Returns a list of TrackInfo objects for a Beatport query.""" bp_tracks = self.client.search(query, release_type="track") tracks = [self._get_track_info(x) for x in bp_tracks] return tracks ================================================ FILE: beetsplug/bench.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Some simple performance benchmarks for beets.""" import cProfile import timeit from beets import importer, library, plugins, ui from beets.autotag import match from beets.plugins import BeetsPlugin from beets.util.functemplate import Template from beetsplug._utils import vfs def aunique_benchmark(lib, prof): def _build_tree(): vfs.libtree(lib) # Measure path generation performance with %aunique{} included. lib.path_formats = [ ( library.PF_KEY_DEFAULT, Template("$albumartist/$album%aunique{}/$track $title"), ), ] if prof: cProfile.runctx( "_build_tree()", {}, {"_build_tree": _build_tree}, "paths.withaunique.prof", ) else: interval = timeit.timeit(_build_tree, number=1) print("With %aunique:", interval) # And with %aunique replaceed with a "cheap" no-op function. lib.path_formats = [ ( library.PF_KEY_DEFAULT, Template("$albumartist/$album%lower{}/$track $title"), ), ] if prof: cProfile.runctx( "_build_tree()", {}, {"_build_tree": _build_tree}, "paths.withoutaunique.prof", ) else: interval = timeit.timeit(_build_tree, number=1) print("Without %aunique:", interval) def match_benchmark(lib, prof, query=None, album_id=None): # If no album ID is provided, we'll match against a suitably huge # album. if not album_id: album_id = "9c5c043e-bc69-4edb-81a4-1aaf9c81e6dc" # Get an album from the library to use as the source for the match. items = lib.albums(query).get().items() # Ensure fingerprinting is invoked (if enabled). plugins.send( "import_task_start", task=importer.ImportTask(None, None, items), session=importer.ImportSession(lib, None, None, None), ) # Run the match. def _run_match(): match.tag_album(items, search_ids=[album_id]) if prof: cProfile.runctx( "_run_match()", {}, {"_run_match": _run_match}, "match.prof" ) else: interval = timeit.timeit(_run_match, number=1) print("match duration:", interval) class BenchmarkPlugin(BeetsPlugin): """A plugin for performing some simple performance benchmarks.""" def commands(self): aunique_bench_cmd = ui.Subcommand( "bench_aunique", help="benchmark for %aunique{}" ) aunique_bench_cmd.parser.add_option( "-p", "--profile", action="store_true", default=False, help="performance profiling", ) aunique_bench_cmd.func = lambda lib, opts, args: aunique_benchmark( lib, opts.profile ) match_bench_cmd = ui.Subcommand( "bench_match", help="benchmark for track matching" ) match_bench_cmd.parser.add_option( "-p", "--profile", action="store_true", default=False, help="performance profiling", ) match_bench_cmd.parser.add_option( "-i", "--id", default=None, help="album ID to match against" ) match_bench_cmd.func = lambda lib, opts, args: match_benchmark( lib, opts.profile, args, opts.id ) return [aunique_bench_cmd, match_bench_cmd] ================================================ FILE: beetsplug/bpd/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A clone of the Music Player Daemon (MPD) that plays music from a Beets library. Attempts to implement a compatible protocol to allow use of the wide range of MPD clients. """ import inspect import math import random import re import socket import sys import time import traceback from string import Template from typing import TYPE_CHECKING, ClassVar import beets import beets.ui from beets import dbcore from beets.library import Item from beets.plugins import BeetsPlugin from beets.util import as_string, bluelet from beetsplug._utils import vfs if TYPE_CHECKING: from beets.dbcore.query import Query try: from . import gstplayer except ImportError as e: raise ImportError( "Gstreamer Python bindings not found." ' Install "gstreamer1.0" and "python-gi" or similar package to use BPD.' ) from e PROTOCOL_VERSION = "0.16.0" BUFSIZE = 1024 HELLO = f"OK MPD {PROTOCOL_VERSION}" CLIST_BEGIN = "command_list_begin" CLIST_VERBOSE_BEGIN = "command_list_ok_begin" CLIST_END = "command_list_end" RESP_OK = "OK" RESP_CLIST_VERBOSE = "list_OK" RESP_ERR = "ACK" NEWLINE = "\n" ERROR_NOT_LIST = 1 ERROR_ARG = 2 ERROR_PASSWORD = 3 ERROR_PERMISSION = 4 ERROR_UNKNOWN = 5 ERROR_NO_EXIST = 50 ERROR_PLAYLIST_MAX = 51 ERROR_SYSTEM = 52 ERROR_PLAYLIST_LOAD = 53 ERROR_UPDATE_ALREADY = 54 ERROR_PLAYER_SYNC = 55 ERROR_EXIST = 56 VOLUME_MIN = 0 VOLUME_MAX = 100 SAFE_COMMANDS = ( # Commands that are available when unauthenticated. "close", "commands", "notcommands", "password", "ping", ) # List of subsystems/events used by the `idle` command. SUBSYSTEMS = [ "update", "player", "mixer", "options", "playlist", "database", # Related to unsupported commands: "stored_playlist", "output", "subscription", "sticker", "message", "partition", ] # Error-handling, exceptions, parameter parsing. class BPDError(Exception): """An error that should be exposed to the client to the BPD server. """ def __init__(self, code, message, cmd_name="", index=0): self.code = code self.message = message self.cmd_name = cmd_name self.index = index template = Template("$resp [$code@$index] {$cmd_name} $message") def response(self): """Returns a string to be used as the response code for the erring command. """ return self.template.substitute( { "resp": RESP_ERR, "code": self.code, "index": self.index, "cmd_name": self.cmd_name, "message": self.message, } ) def make_bpd_error(s_code, s_message): """Create a BPDError subclass for a static code and message.""" class NewBPDError(BPDError): code = s_code message = s_message cmd_name = "" index = 0 def __init__(self): pass return NewBPDError ArgumentTypeError = make_bpd_error(ERROR_ARG, "invalid type for argument") ArgumentIndexError = make_bpd_error(ERROR_ARG, "argument out of range") ArgumentNotFoundError = make_bpd_error(ERROR_NO_EXIST, "argument not found") def cast_arg(t, val): """Attempts to call t on val, raising a ArgumentTypeError on ValueError. If 't' is the special string 'intbool', attempts to cast first to an int and then to a bool (i.e., 1=True, 0=False). """ if t == "intbool": return cast_arg(bool, cast_arg(int, val)) else: try: return t(val) except ValueError: raise ArgumentTypeError() class BPDCloseError(Exception): """Raised by a command invocation to indicate that the connection should be closed. """ class BPDIdleError(Exception): """Raised by a command to indicate the client wants to enter the idle state and should be notified when a relevant event happens. """ def __init__(self, subsystems): super().__init__() self.subsystems = set(subsystems) # Generic server infrastructure, implementing the basic protocol. class BaseServer: """A MPD-compatible music player server. The functions with the `cmd_` prefix are invoked in response to client commands. For instance, if the client says `status`, `cmd_status` will be invoked. The arguments to the client's commands are used as function arguments following the connection issuing the command. The functions may send data on the connection. They may also raise BPDError exceptions to report errors. This is a generic superclass and doesn't support many commands. """ def __init__(self, host, port, password, ctrl_port, log, ctrl_host=None): """Create a new server bound to address `host` and listening on port `port`. If `password` is given, it is required to do anything significant on the server. A separate control socket is established listening to `ctrl_host` on port `ctrl_port` which is used to forward notifications from the player and can be sent debug commands (e.g. using netcat). """ self.host, self.port, self.password = host, port, password self.ctrl_host, self.ctrl_port = ctrl_host or host, ctrl_port self.ctrl_sock = None self._log = log # Default server values. self.random = False self.repeat = False self.consume = False self.single = False self.volume = VOLUME_MAX self.crossfade = 0 self.mixrampdb = 0.0 self.mixrampdelay = float("nan") self.replay_gain_mode = "off" self.playlist = [] self.playlist_version = 0 self.current_index = -1 self.paused = False self.error = None # Current connections self.connections = set() # Object for random numbers generation self.random_obj = random.Random() def connect(self, conn): """A new client has connected.""" self.connections.add(conn) def disconnect(self, conn): """Client has disconnected; clean up residual state.""" self.connections.remove(conn) def run(self): """Block and start listening for connections from clients. An interrupt (^C) closes the server. """ self.startup_time = time.time() def start(): yield bluelet.spawn( bluelet.server( self.ctrl_host, self.ctrl_port, ControlConnection.handler(self), ) ) yield bluelet.server( self.host, self.port, MPDConnection.handler(self) ) bluelet.run(start()) def dispatch_events(self): """If any clients have idle events ready, send them.""" # We need a copy of `self.connections` here since clients might # disconnect once we try and send to them, changing `self.connections`. for conn in list(self.connections): yield bluelet.spawn(conn.send_notifications()) def _ctrl_send(self, message): """Send some data over the control socket. If it's our first time, open the socket. The message should be a string without a terminal newline. """ if not self.ctrl_sock: self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) self.ctrl_sock.sendall((f"{message}\n").encode()) def _send_event(self, event): """Notify subscribed connections of an event.""" for conn in self.connections: conn.notify(event) def _item_info(self, item): """An abstract method that should response lines containing a single song's metadata. """ raise NotImplementedError def _item_id(self, item): """An abstract method returning the integer id for an item.""" raise NotImplementedError def _id_to_index(self, track_id): """Searches the playlist for a song with the given id and returns its index in the playlist. """ track_id = cast_arg(int, track_id) for index, track in enumerate(self.playlist): if self._item_id(track) == track_id: return index # Loop finished with no track found. raise ArgumentNotFoundError() def _random_idx(self): """Returns a random index different from the current one. If there are no songs in the playlist it returns -1. If there is only one song in the playlist it returns 0. """ if len(self.playlist) < 2: return len(self.playlist) - 1 new_index = self.random_obj.randint(0, len(self.playlist) - 1) while new_index == self.current_index: new_index = self.random_obj.randint(0, len(self.playlist) - 1) return new_index def _succ_idx(self): """Returns the index for the next song to play. It also considers random, single and repeat flags. No boundaries are checked. """ if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() return self.current_index + 1 def _prev_idx(self): """Returns the index for the previous song to play. It also considers random and repeat flags. No boundaries are checked. """ if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() return self.current_index - 1 def cmd_ping(self, conn): """Succeeds.""" pass def cmd_idle(self, conn, *subsystems): subsystems = subsystems or SUBSYSTEMS for system in subsystems: if system not in SUBSYSTEMS: raise BPDError(ERROR_ARG, f"Unrecognised idle event: {system}") raise BPDIdleError(subsystems) # put the connection into idle mode def cmd_kill(self, conn): """Exits the server process.""" sys.exit(0) def cmd_close(self, conn): """Closes the connection.""" raise BPDCloseError() def cmd_password(self, conn, password): """Attempts password authentication.""" if password == self.password: conn.authenticated = True else: conn.authenticated = False raise BPDError(ERROR_PASSWORD, "incorrect password") def cmd_commands(self, conn): """Lists the commands available to the user.""" if self.password and not conn.authenticated: # Not authenticated. Show limited list of commands. for cmd in SAFE_COMMANDS: yield f"command: {cmd}" else: # Authenticated. Show all commands. for func in dir(self): if func.startswith("cmd_"): yield f"command: {func[4:]}" def cmd_notcommands(self, conn): """Lists all unavailable commands.""" if self.password and not conn.authenticated: # Not authenticated. Show privileged commands. for func in dir(self): if func.startswith("cmd_"): cmd = func[4:] if cmd not in SAFE_COMMANDS: yield f"command: {cmd}" else: # Authenticated. No commands are unavailable. pass def cmd_status(self, conn): """Returns some status information for use with an implementation of cmd_status. Gives a list of response-lines for: volume, repeat, random, playlist, playlistlength, and xfade. """ yield ( f"repeat: {int(self.repeat)}", f"random: {int(self.random)}", f"consume: {int(self.consume)}", f"single: {int(self.single)}", f"playlist: {self.playlist_version}", f"playlistlength: {len(self.playlist)}", f"mixrampdb: {self.mixrampdb}", ) if self.volume > 0: yield f"volume: {self.volume}" if not math.isnan(self.mixrampdelay): yield f"mixrampdelay: {self.mixrampdelay}" if self.crossfade > 0: yield f"xfade: {self.crossfade}" if self.current_index == -1: state = "stop" elif self.paused: state = "pause" else: state = "play" yield f"state: {state}" if self.current_index != -1: # i.e., paused or playing current_id = self._item_id(self.playlist[self.current_index]) yield f"song: {self.current_index}" yield f"songid: {current_id}" if len(self.playlist) > self.current_index + 1: # If there's a next song, report its index too. next_id = self._item_id(self.playlist[self.current_index + 1]) yield f"nextsong: {self.current_index + 1}" yield f"nextsongid: {next_id}" if self.error: yield f"error: {self.error}" def cmd_clearerror(self, conn): """Removes the persistent error state of the server. This error is set when a problem arises not in response to a command (for instance, when playing a file). """ self.error = None def cmd_random(self, conn, state): """Set or unset random (shuffle) mode.""" self.random = cast_arg("intbool", state) self._send_event("options") def cmd_repeat(self, conn, state): """Set or unset repeat mode.""" self.repeat = cast_arg("intbool", state) self._send_event("options") def cmd_consume(self, conn, state): """Set or unset consume mode.""" self.consume = cast_arg("intbool", state) self._send_event("options") def cmd_single(self, conn, state): """Set or unset single mode.""" # TODO support oneshot in addition to 0 and 1 [MPD 0.20] self.single = cast_arg("intbool", state) self._send_event("options") def cmd_setvol(self, conn, vol): """Set the player's volume level (0-100).""" vol = cast_arg(int, vol) if vol < VOLUME_MIN or vol > VOLUME_MAX: raise BPDError(ERROR_ARG, "volume out of range") self.volume = vol self._send_event("mixer") def cmd_volume(self, conn, vol_delta): """Deprecated command to change the volume by a relative amount.""" vol_delta = cast_arg(int, vol_delta) return self.cmd_setvol(conn, self.volume + vol_delta) def cmd_crossfade(self, conn, crossfade): """Set the number of seconds of crossfading.""" crossfade = cast_arg(int, crossfade) if crossfade < 0: raise BPDError(ERROR_ARG, "crossfade time must be nonnegative") self._log.warning("crossfade is not implemented in bpd") self.crossfade = crossfade self._send_event("options") def cmd_mixrampdb(self, conn, db): """Set the mixramp normalised max volume in dB.""" db = cast_arg(float, db) if db > 0: raise BPDError(ERROR_ARG, "mixrampdb time must be negative") self._log.warning("mixramp is not implemented in bpd") self.mixrampdb = db self._send_event("options") def cmd_mixrampdelay(self, conn, delay): """Set the mixramp delay in seconds.""" delay = cast_arg(float, delay) if delay < 0: raise BPDError(ERROR_ARG, "mixrampdelay time must be nonnegative") self._log.warning("mixramp is not implemented in bpd") self.mixrampdelay = delay self._send_event("options") def cmd_replay_gain_mode(self, conn, mode): """Set the replay gain mode.""" if mode not in ["off", "track", "album", "auto"]: raise BPDError(ERROR_ARG, "Unrecognised replay gain mode") self._log.warning("replay gain is not implemented in bpd") self.replay_gain_mode = mode self._send_event("options") def cmd_replay_gain_status(self, conn): """Get the replaygain mode.""" yield f"replay_gain_mode: {self.replay_gain_mode}" def cmd_clear(self, conn): """Clear the playlist.""" self.playlist = [] self.playlist_version += 1 self.cmd_stop(conn) self._send_event("playlist") def cmd_delete(self, conn, index): """Remove the song at index from the playlist.""" index = cast_arg(int, index) try: del self.playlist[index] except IndexError: raise ArgumentIndexError() self.playlist_version += 1 if self.current_index == index: # Deleted playing song. self.cmd_stop(conn) elif index < self.current_index: # Deleted before playing. # Shift playing index down. self.current_index -= 1 self._send_event("playlist") def cmd_deleteid(self, conn, track_id): self.cmd_delete(conn, self._id_to_index(track_id)) def cmd_move(self, conn, idx_from, idx_to): """Move a track in the playlist.""" idx_from = cast_arg(int, idx_from) idx_to = cast_arg(int, idx_to) try: track = self.playlist.pop(idx_from) self.playlist.insert(idx_to, track) except IndexError: raise ArgumentIndexError() # Update currently-playing song. if idx_from == self.current_index: self.current_index = idx_to elif idx_from < self.current_index <= idx_to: self.current_index -= 1 elif idx_from > self.current_index >= idx_to: self.current_index += 1 self.playlist_version += 1 self._send_event("playlist") def cmd_moveid(self, conn, idx_from, idx_to): idx_from = self._id_to_index(idx_from) return self.cmd_move(conn, idx_from, idx_to) def cmd_swap(self, conn, i, j): """Swaps two tracks in the playlist.""" i = cast_arg(int, i) j = cast_arg(int, j) try: track_i = self.playlist[i] track_j = self.playlist[j] except IndexError: raise ArgumentIndexError() self.playlist[j] = track_i self.playlist[i] = track_j # Update currently-playing song. if self.current_index == i: self.current_index = j elif self.current_index == j: self.current_index = i self.playlist_version += 1 self._send_event("playlist") def cmd_swapid(self, conn, i_id, j_id): i = self._id_to_index(i_id) j = self._id_to_index(j_id) return self.cmd_swap(conn, i, j) def cmd_urlhandlers(self, conn): """Indicates supported URL schemes. None by default.""" pass def cmd_playlistinfo(self, conn, index=None): """Gives metadata information about the entire playlist or a single track, given by its index. """ if index is None: for track in self.playlist: yield self._item_info(track) else: indices = self._parse_range(index, accept_single_number=True) try: tracks = [self.playlist[i] for i in indices] except IndexError: raise ArgumentIndexError() for track in tracks: yield self._item_info(track) def cmd_playlistid(self, conn, track_id=None): if track_id is not None: track_id = cast_arg(int, track_id) track_id = self._id_to_index(track_id) return self.cmd_playlistinfo(conn, track_id) def cmd_plchanges(self, conn, version): """Sends playlist changes since the given version. This is a "fake" implementation that ignores the version and just returns the entire playlist (rather like version=0). This seems to satisfy many clients. """ return self.cmd_playlistinfo(conn) def cmd_plchangesposid(self, conn, version): """Like plchanges, but only sends position and id. Also a dummy implementation. """ for idx, track in enumerate(self.playlist): yield f"cpos: {idx}" yield f"Id: {track.id}" def cmd_currentsong(self, conn): """Sends information about the currently-playing song.""" if self.current_index != -1: # -1 means stopped. track = self.playlist[self.current_index] yield self._item_info(track) def cmd_next(self, conn): """Advance to the next song in the playlist.""" old_index = self.current_index self.current_index = self._succ_idx() if self.consume: # TODO how does consume interact with single+repeat? self.playlist.pop(old_index) if self.current_index > old_index: self.current_index -= 1 self.playlist_version += 1 self._send_event("playlist") if self.current_index >= len(self.playlist): # Fallen off the end. Move to stopped state or loop. if self.repeat: self.current_index = -1 return self.cmd_play(conn) return self.cmd_stop(conn) elif self.single and not self.repeat: return self.cmd_stop(conn) else: return self.cmd_play(conn) def cmd_previous(self, conn): """Step back to the last song.""" old_index = self.current_index self.current_index = self._prev_idx() if self.consume: self.playlist.pop(old_index) if self.current_index < 0: if self.repeat: self.current_index = len(self.playlist) - 1 else: self.current_index = 0 return self.cmd_play(conn) def cmd_pause(self, conn, state=None): """Set the pause state playback.""" if state is None: self.paused = not self.paused # Toggle. else: self.paused = cast_arg("intbool", state) self._send_event("player") def cmd_play(self, conn, index=-1): """Begin playback, possibly at a specified playlist index.""" index = cast_arg(int, index) if index < -1 or index >= len(self.playlist): raise ArgumentIndexError() if index == -1: # No index specified: start where we are. if not self.playlist: # Empty playlist: stop immediately. return self.cmd_stop(conn) if self.current_index == -1: # No current song. self.current_index = 0 # Start at the beginning. # If we have a current song, just stay there. else: # Start with the specified index. self.current_index = index self.paused = False self._send_event("player") def cmd_playid(self, conn, track_id=0): track_id = cast_arg(int, track_id) if track_id == -1: index = -1 else: index = self._id_to_index(track_id) return self.cmd_play(conn, index) def cmd_stop(self, conn): """Stop playback.""" self.current_index = -1 self.paused = False self._send_event("player") def cmd_seek(self, conn, index, pos): """Seek to a specified point in a specified song.""" index = cast_arg(int, index) if index < 0 or index >= len(self.playlist): raise ArgumentIndexError() self.current_index = index self._send_event("player") def cmd_seekid(self, conn, track_id, pos): index = self._id_to_index(track_id) return self.cmd_seek(conn, index, pos) # Additions to the MPD protocol. def cmd_crash(self, conn): """Deliberately trigger a TypeError for testing purposes. We want to test that the server properly responds with ERROR_SYSTEM without crashing, and that this is not treated as ERROR_ARG (since it is caused by a programming error, not a protocol error). """ raise TypeError class Connection: """A connection between a client and the server.""" def __init__(self, server, sock): """Create a new connection for the accepted socket `client`.""" self.server = server self.sock = sock self.address = ":".join(map(str, sock.sock.getpeername())) def debug(self, message, kind=" "): """Log a debug message about this connection.""" self.server._log.debug("{}[{.address}]: {}", kind, self, message) def run(self): pass def send(self, lines): """Send lines, which which is either a single string or an iterable consisting of strings, to the client. A newline is added after every string. Returns a Bluelet event that sends the data. """ if isinstance(lines, str): lines = [lines] out = NEWLINE.join(lines) + NEWLINE for line in out.split(NEWLINE)[:-1]: self.debug(line, kind=">") if isinstance(out, str): out = out.encode("utf-8") return self.sock.sendall(out) @classmethod def handler(cls, server): def _handle(sock): """Creates a new `Connection` and runs it.""" return cls(server, sock).run() return _handle class MPDConnection(Connection): """A connection that receives commands from an MPD-compatible client.""" def __init__(self, server, sock): """Create a new connection for the accepted socket `client`.""" super().__init__(server, sock) self.authenticated = False self.notifications = set() self.idle_subscriptions = set() def do_command(self, command): """A coroutine that runs the given command and sends an appropriate response.""" try: yield bluelet.call(command.run(self)) except BPDError as e: # Send the error. yield self.send(e.response()) else: # Send success code. yield self.send(RESP_OK) def disconnect(self): """The connection has closed for any reason.""" self.server.disconnect(self) self.debug("disconnected", kind="*") def notify(self, event): """Queue up an event for sending to this client.""" self.notifications.add(event) def send_notifications(self, force_close_idle=False): """Send the client any queued events now.""" pending = self.notifications.intersection(self.idle_subscriptions) try: for event in pending: yield self.send(f"changed: {event}") if pending or force_close_idle: self.idle_subscriptions = set() self.notifications = self.notifications.difference(pending) yield self.send(RESP_OK) except bluelet.SocketClosedError: self.disconnect() # Client disappeared. def run(self): """Send a greeting to the client and begin processing commands as they arrive. """ self.debug("connected", kind="*") self.server.connect(self) yield self.send(HELLO) clist = None # Initially, no command list is being constructed. while True: line = yield self.sock.readline() if not line: self.disconnect() # Client disappeared. break line = line.strip() if not line: err = BPDError(ERROR_UNKNOWN, "No command given") yield self.send(err.response()) self.disconnect() # Client sent a blank line. break line = line.decode("utf8") # MPD protocol uses UTF-8. for line in line.split(NEWLINE): self.debug(line, kind="<") if self.idle_subscriptions: # The connection is in idle mode. if line == "noidle": yield bluelet.call(self.send_notifications(True)) else: err = BPDError( ERROR_UNKNOWN, f"Got command while idle: {line}" ) yield self.send(err.response()) break continue if line == "noidle": # When not in idle, this command sends no response. continue if clist is not None: # Command list already opened. if line == CLIST_END: yield bluelet.call(self.do_command(clist)) clist = None # Clear the command list. yield bluelet.call(self.server.dispatch_events()) else: clist.append(Command(line)) elif line == CLIST_BEGIN or line == CLIST_VERBOSE_BEGIN: # Begin a command list. clist = CommandList([], line == CLIST_VERBOSE_BEGIN) else: # Ordinary command. try: yield bluelet.call(self.do_command(Command(line))) except BPDCloseError: # Command indicates that the conn should close. self.sock.close() self.disconnect() # Client explicitly closed. return except BPDIdleError as e: self.idle_subscriptions = e.subsystems self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z") yield bluelet.call(self.server.dispatch_events()) class ControlConnection(Connection): """A connection used to control BPD for debugging and internal events.""" def __init__(self, server, sock): """Create a new connection for the accepted socket `client`.""" super().__init__(server, sock) def debug(self, message, kind=" "): self.server._log.debug("CTRL {}[{.address}]: {}", kind, self, message) def run(self): """Listen for control commands and delegate to `ctrl_*` methods.""" self.debug("connected", kind="*") while True: line = yield self.sock.readline() if not line: break # Client disappeared. line = line.strip() if not line: break # Client sent a blank line. line = line.decode("utf8") # Protocol uses UTF-8. for line in line.split(NEWLINE): self.debug(line, kind="<") command = Command(line) try: func = command.delegate("ctrl_", self) yield bluelet.call(func(*command.args)) except (AttributeError, TypeError) as e: yield self.send(f"ERROR: {e.args[0]}") except Exception: yield self.send( ["ERROR: server error", traceback.format_exc().rstrip()] ) def ctrl_play_finished(self): """Callback from the player signalling a song finished playing.""" yield bluelet.call(self.server.dispatch_events()) def ctrl_profile(self): """Memory profiling for debugging.""" from guppy import hpy heap = hpy().heap() yield self.send(heap) def ctrl_nickname(self, oldlabel, newlabel): """Rename a client in the log messages.""" for c in self.server.connections: if c.address == oldlabel: c.address = newlabel break else: yield self.send(f"ERROR: no such client: {oldlabel}") class Command: """A command issued by the client for processing by the server.""" command_re = re.compile(r"^([^ \t]+)[ \t]*") arg_re = re.compile(r'"((?:\\"|[^"])+)"|([^ \t"]+)') def __init__(self, s): """Creates a new `Command` from the given string, `s`, parsing the string for command name and arguments. """ command_match = self.command_re.match(s) self.name = command_match.group(1) self.args = [] arg_matches = self.arg_re.findall(s[command_match.end() :]) for match in arg_matches: if match[0]: # Quoted argument. arg = match[0] arg = arg.replace('\\"', '"').replace("\\\\", "\\") else: # Unquoted argument. arg = match[1] self.args.append(arg) def delegate(self, prefix, target, extra_args=0): """Get the target method that corresponds to this command. The `prefix` is prepended to the command name and then the resulting name is used to search `target` for a method with a compatible number of arguments. """ # Attempt to get correct command function. func_name = f"{prefix}{self.name}" if not hasattr(target, func_name): raise AttributeError(f'unknown command "{self.name}"') func = getattr(target, func_name) argspec = inspect.getfullargspec(func) # Check that `func` is able to handle the number of arguments sent # by the client (so we can raise ERROR_ARG instead of ERROR_SYSTEM). # Maximum accepted arguments: argspec includes "self". max_args = len(argspec.args) - 1 - extra_args # Minimum accepted arguments: some arguments might be optional. min_args = max_args if argspec.defaults: min_args -= len(argspec.defaults) wrong_num = (len(self.args) > max_args) or (len(self.args) < min_args) # If the command accepts a variable number of arguments skip the check. if wrong_num and not argspec.varargs: raise TypeError( f'wrong number of arguments for "{self.name}"', self.name, ) return func def run(self, conn): """A coroutine that executes the command on the given connection. """ try: # `conn` is an extra argument to all cmd handlers. func = self.delegate("cmd_", conn.server, extra_args=1) except AttributeError as e: raise BPDError(ERROR_UNKNOWN, e.args[0]) except TypeError as e: raise BPDError(ERROR_ARG, e.args[0], self.name) # Ensure we have permission for this command. if ( conn.server.password and not conn.authenticated and self.name not in SAFE_COMMANDS ): raise BPDError(ERROR_PERMISSION, "insufficient privileges") try: args = [conn, *self.args] results = func(*args) if results: for data in results: yield conn.send(data) except BPDError as e: # An exposed error. Set the command name and then let # the Connection handle it. e.cmd_name = self.name raise e except BPDCloseError: # An indication that the connection should close. Send # it on the Connection. raise except BPDIdleError: raise except Exception: # An "unintentional" error. Hide it from the client. conn.server._log.error("{}", traceback.format_exc()) raise BPDError(ERROR_SYSTEM, "server error", self.name) class CommandList(list[Command]): """A list of commands issued by the client for processing by the server. May be verbose, in which case the response is delimited, or not. Should be a list of `Command` objects. """ def __init__(self, sequence=None, verbose=False): """Create a new `CommandList` from the given sequence of `Command`s. If `verbose`, this is a verbose command list. """ if sequence: for item in sequence: self.append(item) self.verbose = verbose def run(self, conn): """Coroutine executing all the commands in this list.""" for i, command in enumerate(self): try: yield bluelet.call(command.run(conn)) except BPDError as e: # If the command failed, stop executing. e.index = i # Give the error the correct index. raise e # Otherwise, possibly send the output delimiter if we're in a # verbose ("OK") command list. if self.verbose: yield conn.send(RESP_CLIST_VERBOSE) # A subclass of the basic, protocol-handling server that actually plays # music. class Server(BaseServer): """An MPD-compatible server using GStreamer to play audio and beets to store its library. """ def __init__(self, library, host, port, password, ctrl_port, log): log.info("Starting server...") super().__init__(host, port, password, ctrl_port, log) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) log.info("Server ready and listening on {}:{}", host, port) log.debug("Listening for control signals on {}:{}", host, ctrl_port) def run(self): self.player.run() super().run() def play_finished(self): """A callback invoked every time our player finishes a track.""" self.cmd_next(None) self._ctrl_send("play_finished") # Metadata helper functions. def _item_info(self, item): info_lines = [ f"file: {as_string(item.destination(relative_to_libdir=True))}", f"Time: {int(item.length)}", "duration: {item.length:.3f}", f"Id: {item.id}", ] try: pos = self._id_to_index(item.id) info_lines.append(f"Pos: {pos}") except ArgumentNotFoundError: # Don't include position if not in playlist. pass for tagtype, field in self.tagtype_map.items(): field_value = getattr(item, field) if isinstance(field_value, list): field_value = "; ".join(field_value) info_lines.append(f"{tagtype}: {field_value}") return info_lines def _parse_range(self, items, accept_single_number=False): """Convert a range of positions to a list of item info. MPD specifies ranges as START:STOP (endpoint excluded) for some commands. Sometimes a single number can be provided instead. """ try: start, stop = str(items).split(":", 1) except ValueError: if accept_single_number: return [cast_arg(int, items)] raise BPDError(ERROR_ARG, "bad range syntax") start = cast_arg(int, start) stop = cast_arg(int, stop) return range(start, stop) def _item_id(self, item): return item.id # Database updating. def cmd_update(self, conn, path="/"): """Updates the catalog to reflect the current database state.""" # Path is ignored. Also, the real MPD does this asynchronously; # this is done inline. self._log.debug("Building directory tree...") self.tree = vfs.libtree(self.lib) self._log.debug("Finished building directory tree.") self.updated_time = time.time() self._send_event("update") self._send_event("database") # Path (directory tree) browsing. def _resolve_path(self, path): """Returns a VFS node or an item ID located at the path given. If the path does not exist, raises a """ components = path.split("/") node = self.tree for component in components: if not component: continue if isinstance(node, int): # We're trying to descend into a file node. raise ArgumentNotFoundError() if component in node.files: node = node.files[component] elif component in node.dirs: node = node.dirs[component] else: raise ArgumentNotFoundError() return node def _path_join(self, p1, p2): """Smashes together two BPD paths.""" out = f"{p1}/{p2}" return out.replace("//", "/").replace("//", "/") def cmd_lsinfo(self, conn, path="/"): """Sends info on all the items in the path.""" node = self._resolve_path(path) if isinstance(node, int): # Trying to list a track. raise BPDError(ERROR_ARG, "this is not a directory") else: for name, itemid in iter(sorted(node.files.items())): item = self.lib.get_item(itemid) yield self._item_info(item) for name, _ in iter(sorted(node.dirs.items())): dirpath = self._path_join(path, name) if dirpath.startswith("/"): # Strip leading slash (libmpc rejects this). dirpath = dirpath[1:] yield f"directory: {dirpath}" def _listall(self, basepath, node, info=False): """Helper function for recursive listing. If info, show tracks' complete info; otherwise, just show items' paths. """ if isinstance(node, int): # List a single file. if info: item = self.lib.get_item(node) yield self._item_info(item) else: yield f"file: {basepath}" else: # List a directory. Recurse into both directories and files. for name, itemid in sorted(node.files.items()): newpath = self._path_join(basepath, name) # "yield from" yield from self._listall(newpath, itemid, info) for name, subdir in sorted(node.dirs.items()): newpath = self._path_join(basepath, name) yield f"directory: {newpath}" yield from self._listall(newpath, subdir, info) def cmd_listall(self, conn, path="/"): """Send the paths all items in the directory, recursively.""" return self._listall(path, self._resolve_path(path), False) def cmd_listallinfo(self, conn, path="/"): """Send info on all the items in the directory, recursively.""" return self._listall(path, self._resolve_path(path), True) # Playlist manipulation. def _all_items(self, node): """Generator yielding all items under a VFS node.""" if isinstance(node, int): # Could be more efficient if we built up all the IDs and # then issued a single SELECT. yield self.lib.get_item(node) else: # Recurse into a directory. for name, itemid in sorted(node.files.items()): # "yield from" yield from self._all_items(itemid) for name, subdir in sorted(node.dirs.items()): yield from self._all_items(subdir) def _add(self, path, send_id=False): """Adds a track or directory to the playlist, specified by the path. If `send_id`, write each item's id to the client. """ for item in self._all_items(self._resolve_path(path)): self.playlist.append(item) if send_id: yield f"Id: {item.id}" self.playlist_version += 1 self._send_event("playlist") def cmd_add(self, conn, path): """Adds a track or directory to the playlist, specified by a path. """ return self._add(path, False) def cmd_addid(self, conn, path): """Same as `cmd_add` but sends an id back to the client.""" return self._add(path, True) # Server info. def cmd_status(self, conn): yield from super().cmd_status(conn) if self.current_index > -1: item = self.playlist[self.current_index] yield ( f"bitrate: {item.bitrate / 1000}", f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}", ) (pos, total) = self.player.time() yield ( f"time: {int(pos)}:{int(total)}", "elapsed: " + f"{pos:.3f}", "duration: " + f"{total:.3f}", ) # Also missing 'updating_db'. def cmd_stats(self, conn): """Sends some statistics about the library.""" with self.lib.transaction() as tx: statement = ( "SELECT COUNT(DISTINCT artist), " "COUNT(DISTINCT album), " "COUNT(id), " "SUM(length) " "FROM items" ) artists, albums, songs, totaltime = tx.query(statement)[0] yield ( f"artists: {artists}", f"albums: {albums}", f"songs: {songs}", f"uptime: {int(time.time() - self.startup_time)}", "playtime: 0", # Missing. f"db_playtime: {int(totaltime)}", f"db_update: {int(self.updated_time)}", ) def cmd_decoders(self, conn): """Send list of supported decoders and formats.""" decoders = self.player.get_decoders() for name, (mimes, exts) in decoders.items(): yield f"plugin: {name}" for ext in exts: yield f"suffix: {ext}" for mime in mimes: yield f"mime_type: {mime}" # Searching. tagtype_map: ClassVar[dict[str, str]] = { "Artist": "artist", "ArtistSort": "artist_sort", "Album": "album", "Title": "title", "Track": "track", "AlbumArtist": "albumartist", "AlbumArtistSort": "albumartist_sort", "Label": "label", "Genre": "genres", "Date": "year", "OriginalDate": "original_year", "Composer": "composer", "Disc": "disc", "Comment": "comments", "MUSICBRAINZ_TRACKID": "mb_trackid", "MUSICBRAINZ_ALBUMID": "mb_albumid", "MUSICBRAINZ_ARTISTID": "mb_artistid", "MUSICBRAINZ_ALBUMARTISTID": "mb_albumartistid", "MUSICBRAINZ_RELEASETRACKID": "mb_releasetrackid", } def cmd_tagtypes(self, conn): """Returns a list of the metadata (tag) fields available for searching. """ for tag in self.tagtype_map: yield f"tagtype: {tag}" def _tagtype_lookup(self, tag): """Uses `tagtype_map` to look up the beets column name for an MPD tagtype (or throw an appropriate exception). Returns both the canonical name of the MPD tagtype and the beets column name. """ for test_tag, key in self.tagtype_map.items(): # Match case-insensitively. if test_tag.lower() == tag.lower(): return test_tag, key raise BPDError(ERROR_UNKNOWN, "no such tagtype") def _metadata_query(self, query_type, kv, allow_any_query: bool = False): """Helper function returns a query object that will find items according to the library query type provided and the key-value pairs specified. The any_query_type is used for queries of type "any"; if None, then an error is thrown. """ if kv: # At least one key-value pair. queries: list[Query] = [] # Iterate pairwise over the arguments. it = iter(kv) for tag, value in zip(it, it): if tag.lower() == "any": if allow_any_query: queries.append( Item.any_writable_media_field_query( query_type, value ) ) else: raise BPDError(ERROR_UNKNOWN, "no such tagtype") else: _, key = self._tagtype_lookup(tag) queries.append(Item.field_query(key, value, query_type)) return dbcore.query.AndQuery(queries) else: # No key-value pairs. return dbcore.query.TrueQuery() def cmd_search(self, conn, *kv): """Perform a substring match for items.""" query = self._metadata_query( dbcore.query.SubstringQuery, kv, allow_any_query=True ) for item in self.lib.items(query): yield self._item_info(item) def cmd_find(self, conn, *kv): """Perform an exact match for items.""" query = self._metadata_query(dbcore.query.MatchQuery, kv) for item in self.lib.items(query): yield self._item_info(item) def cmd_list(self, conn, show_tag, *kv): """List distinct metadata values for show_tag, possibly filtered by matching match_tag to match_term. """ show_tag_canon, show_key = self._tagtype_lookup(show_tag) if len(kv) == 1: if show_tag_canon == "Album": # If no tag was given, assume artist. This is because MPD # supports a short version of this command for fetching the # albums belonging to a particular artist, and some clients # rely on this behaviour (e.g. MPDroid, M.A.L.P.). kv = ("Artist", kv[0]) else: raise BPDError(ERROR_ARG, 'should be "Album" for 3 arguments') elif len(kv) % 2 != 0: raise BPDError(ERROR_ARG, "Incorrect number of filter arguments") query = self._metadata_query(dbcore.query.MatchQuery, kv) clause, subvals = query.clause() statement = ( f"SELECT DISTINCT {show_key}" f" FROM items WHERE {clause}" f" ORDER BY {show_key}" ) self._log.debug(statement) with self.lib.transaction() as tx: rows = tx.query(statement, subvals) for row in rows: if not row[0]: # Skip any empty values of the field. continue yield f"{show_tag_canon}: {row[0]}" def cmd_count(self, conn, tag, value): """Returns the number and total time of songs matching the tag/value query. """ _, key = self._tagtype_lookup(tag) songs = 0 playtime = 0.0 for item in self.lib.items( Item.field_query(key, value, dbcore.query.MatchQuery) ): songs += 1 playtime += item.length yield f"songs: {songs}" yield f"playtime: {int(playtime)}" # Persistent playlist manipulation. In MPD this is an optional feature so # these dummy implementations match MPD's behaviour with the feature off. def cmd_listplaylist(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, "No such playlist") def cmd_listplaylistinfo(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, "No such playlist") def cmd_listplaylists(self, conn): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_load(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, "Stored playlists are disabled") def cmd_playlistadd(self, conn, playlist, uri): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_playlistclear(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_playlistdelete(self, conn, playlist, index): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_playlistmove(self, conn, playlist, from_index, to_index): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_rename(self, conn, playlist, new_name): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_rm(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") def cmd_save(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, "Stored playlists are disabled") # "Outputs." Just a dummy implementation because we don't control # any outputs. def cmd_outputs(self, conn): """List the available outputs.""" yield ( "outputid: 0", "outputname: gstreamer", "outputenabled: 1", ) def cmd_enableoutput(self, conn, output_id): output_id = cast_arg(int, output_id) if output_id != 0: raise ArgumentIndexError() def cmd_disableoutput(self, conn, output_id): output_id = cast_arg(int, output_id) if output_id == 0: raise BPDError(ERROR_ARG, "cannot disable this output") else: raise ArgumentIndexError() # Playback control. The functions below hook into the # half-implementations provided by the base class. Together, they're # enough to implement all normal playback functionality. def cmd_play(self, conn, index=-1): new_index = index != -1 and index != self.current_index was_paused = self.paused super().cmd_play(conn, index) if self.current_index > -1: # Not stopped. if was_paused and not new_index: # Just unpause. self.player.play() else: self.player.play_file(self.playlist[self.current_index].path) def cmd_pause(self, conn, state=None): super().cmd_pause(conn, state) if self.paused: self.player.pause() elif self.player.playing: self.player.play() def cmd_stop(self, conn): super().cmd_stop(conn) self.player.stop() def cmd_seek(self, conn, index, pos): """Seeks to the specified position in the specified song.""" index = cast_arg(int, index) pos = cast_arg(float, pos) super().cmd_seek(conn, index, pos) self.player.seek(pos) # Volume control. def cmd_setvol(self, conn, vol): vol = cast_arg(int, vol) super().cmd_setvol(conn, vol) self.player.volume = float(vol) / 100 # Beets plugin hooks. class BPDPlugin(BeetsPlugin): """Provides the "beet bpd" command for running a music player server. """ def __init__(self): super().__init__() self.config.add( { "host": "", "port": 6600, "control_port": 6601, "password": "", "volume": VOLUME_MAX, } ) self.config["password"].redact = True def start_bpd(self, lib, host, port, password, volume, ctrl_port): """Starts a BPD server.""" server = Server(lib, host, port, password, ctrl_port, self._log) server.cmd_setvol(None, volume) server.run() def commands(self): cmd = beets.ui.Subcommand( "bpd", help="run an MPD-compatible music player server" ) def func(lib, opts, args): host = self.config["host"].as_str() host = args.pop(0) if args else host port = args.pop(0) if args else self.config["port"].get(int) if args: ctrl_port = args.pop(0) else: ctrl_port = self.config["control_port"].get(int) if args: raise beets.ui.UserError("too many arguments") password = self.config["password"].as_str() volume = self.config["volume"].get(int) self.start_bpd( lib, host, int(port), password, volume, int(ctrl_port) ) cmd.func = func return [cmd] ================================================ FILE: beetsplug/bpd/gstplayer.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A wrapper for the GStreamer Python bindings that exposes a simple music player. """ import _thread import copy import os import sys import time import urllib import gi from beets import ui try: gi.require_version("Gst", "1.0") except ValueError as e: # on some scenarios, gi may be importable, but we get a ValueError when # trying to specify the required version. This is problematic in the test # suite where test_bpd.py has a call to # pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError # makes it so the test collector functions as inteded. raise ImportError from e from gi.repository import GLib, Gst Gst.init(None) class QueryError(Exception): pass class GstPlayer: """A music player abstracting GStreamer's Playbin element. Create a player object, then call run() to start a thread with a runloop. Then call play_file to play music. Use player.playing to check whether music is currently playing. A basic play queue is also implemented (just a Python list, player.queue, whose last element is next to play). To use it, just call enqueue() and then play(). When a track finishes and another is available on the queue, it is played automatically. """ def __init__(self, finished_callback=None): """Initialize a player. If a finished_callback is provided, it is called every time a track started with play_file finishes. Once the player has been created, call run() to begin the main runloop in a separate thread. """ # Set up the Gstreamer player. From the pygst tutorial: # https://pygstdocs.berlios.de/pygst-tutorial/playbin.html (gone) # https://brettviren.github.io/pygst-tutorial-org/pygst-tutorial.html #### # Updated to GStreamer 1.0 with: # https://wiki.ubuntu.com/Novacut/GStreamer1.0 self.player = Gst.ElementFactory.make("playbin", "player") if self.player is None: raise ui.UserError("Could not create playbin") fakesink = Gst.ElementFactory.make("fakesink", "fakesink") if fakesink is None: raise ui.UserError("Could not create fakesink") self.player.set_property("video-sink", fakesink) bus = self.player.get_bus() bus.add_signal_watch() bus.connect("message", self._handle_message) # Set up our own stuff. self.playing = False self.finished_callback = finished_callback self.cached_time = None self._volume = 1.0 def _get_state(self): """Returns the current state flag of the playbin.""" # gst's get_state function returns a 3-tuple; we just want the # status flag in position 1. return self.player.get_state(Gst.CLOCK_TIME_NONE)[1] def _handle_message(self, bus, message): """Callback for status updates from GStreamer.""" if message.type == Gst.MessageType.EOS: # file finished playing self.player.set_state(Gst.State.NULL) self.playing = False self.cached_time = None if self.finished_callback: self.finished_callback() elif message.type == Gst.MessageType.ERROR: # error self.player.set_state(Gst.State.NULL) err, _ = message.parse_error() print(f"Error: {err}") self.playing = False def _set_volume(self, volume): """Set the volume level to a value in the range [0, 1.5].""" # And the volume for the playbin. self._volume = volume self.player.set_property("volume", volume) def _get_volume(self): """Get the volume as a float in the range [0, 1.5].""" return self._volume volume = property(_get_volume, _set_volume) def play_file(self, path): """Immediately begin playing the audio file at the given path. """ self.player.set_state(Gst.State.NULL) if isinstance(path, str): path = path.encode("utf-8") uri = f"file://{urllib.parse.quote(path)}" self.player.set_property("uri", uri) self.player.set_state(Gst.State.PLAYING) self.playing = True def play(self): """If paused, resume playback.""" if self._get_state() == Gst.State.PAUSED: self.player.set_state(Gst.State.PLAYING) self.playing = True def pause(self): """Pause playback.""" self.player.set_state(Gst.State.PAUSED) def stop(self): """Halt playback.""" self.player.set_state(Gst.State.NULL) self.playing = False self.cached_time = None def run(self): """Start a new thread for the player. Call this function before trying to play any music with play_file() or play(). """ # If we don't use the MainLoop, messages are never sent. def start(): loop = GLib.MainLoop() loop.run() _thread.start_new_thread(start, ()) def time(self): """Returns a tuple containing (position, length) where both values are integers in seconds. If no stream is available, returns (0, 0). """ fmt = Gst.Format(Gst.Format.TIME) try: posq = self.player.query_position(fmt) if not posq[0]: raise QueryError("query_position failed") pos = posq[1] / (10**9) lengthq = self.player.query_duration(fmt) if not lengthq[0]: raise QueryError("query_duration failed") length = lengthq[1] / (10**9) self.cached_time = (pos, length) return (pos, length) except QueryError: # Stream not ready. For small gaps of time, for instance # after seeking, the time values are unavailable. For this # reason, we cache recent. if self.playing and self.cached_time: return self.cached_time else: return (0, 0) def seek(self, position): """Seeks to position (in seconds).""" _, cur_len = self.time() if position > cur_len: self.stop() return fmt = Gst.Format(Gst.Format.TIME) ns = position * 10**9 # convert to nanoseconds self.player.seek_simple(fmt, Gst.SeekFlags.FLUSH, ns) # save new cached time self.cached_time = (position, cur_len) def block(self): """Block until playing finishes.""" while self.playing: time.sleep(1) def get_decoders(self): return get_decoders() def get_decoders(): """Get supported audio decoders from GStreamer. Returns a dict mapping decoder element names to the associated media types and file extensions. """ # We only care about audio decoder elements. filt = ( Gst.ELEMENT_FACTORY_TYPE_DEPAYLOADER | Gst.ELEMENT_FACTORY_TYPE_DEMUXER | Gst.ELEMENT_FACTORY_TYPE_PARSER | Gst.ELEMENT_FACTORY_TYPE_DECODER | Gst.ELEMENT_FACTORY_TYPE_MEDIA_AUDIO ) decoders = {} mime_types = set() for f in Gst.ElementFactory.list_get_elements(filt, Gst.Rank.NONE): for pad in f.get_static_pad_templates(): if pad.direction == Gst.PadDirection.SINK: caps = pad.static_caps.get() mimes = set() for i in range(caps.get_size()): struct = caps.get_structure(i) mime = struct.get_name() if mime == "unknown/unknown": continue mimes.add(mime) mime_types.add(mime) if mimes: decoders[f.get_name()] = (mimes, set()) # Check all the TypeFindFactory plugin features form the registry. If they # are associated with an audio media type that we found above, get the list # of corresponding file extensions. mime_extensions = {mime: set() for mime in mime_types} for feat in Gst.Registry.get().get_feature_list(Gst.TypeFindFactory): caps = feat.get_caps() if caps: for i in range(caps.get_size()): struct = caps.get_structure(i) mime = struct.get_name() if mime in mime_types: mime_extensions[mime].update(feat.get_extensions()) # Fill in the slot we left for file extensions. for name, (mimes, exts) in decoders.items(): for mime in mimes: exts.update(mime_extensions[mime]) return decoders def play_simple(paths): """Play the files in paths in a straightforward way, without using the player's callback function. """ p = GstPlayer() p.run() for path in paths: p.play_file(path) p.block() def play_complicated(paths): """Play the files in the path one after the other by using the callback function to advance to the next song. """ my_paths = copy.copy(paths) def next_song(): my_paths.pop(0) p.play_file(my_paths[0]) p = GstPlayer(next_song) p.run() p.play_file(my_paths[0]) while my_paths: time.sleep(1) if __name__ == "__main__": # A very simple command-line player. Just give it names of audio # files on the command line; these are all played in sequence. paths = [os.path.abspath(os.path.expanduser(p)) for p in sys.argv[1:]] # play_simple(paths) play_complicated(paths) ================================================ FILE: beetsplug/bpm.py ================================================ # This file is part of beets. # Copyright 2016, aroquen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Determine BPM by pressing a key to the rhythm.""" import time from beets import ui from beets.plugins import BeetsPlugin def bpm(max_strokes): """Returns average BPM (possibly of a playing song) listening to Enter keystrokes. """ t0 = None dt = [] for i in range(max_strokes): # Press enter to the rhythm... s = input() if s == "": t1 = time.time() # Only start measuring at the second stroke if t0: dt.append(t1 - t0) t0 = t1 else: break # Return average BPM # bpm = (max_strokes-1) / sum(dt) * 60 ave = sum([1.0 / dti * 60 for dti in dt]) / len(dt) return ave class BPMPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "max_strokes": 3, "overwrite": True, } ) def commands(self): cmd = ui.Subcommand( "bpm", help="determine bpm of a song by pressing a key to the rhythm", ) cmd.func = self.command return [cmd] def command(self, lib, opts, args): write = ui.should_write() self.get_bpm(lib.items(args), write) def get_bpm(self, items, write=False): overwrite = self.config["overwrite"].get(bool) if len(items) > 1: raise ValueError("Can only get bpm of one song at time") item = items[0] if item["bpm"]: self._log.info("Found bpm {}", item["bpm"]) if not overwrite: return self._log.info( "Press Enter {} times to the rhythm or Ctrl-D to exit", self.config["max_strokes"].get(int), ) new_bpm = bpm(self.config["max_strokes"].get(int)) item["bpm"] = int(new_bpm) if write: item.try_write() item.store() self._log.info("Added new bpm {}", item["bpm"]) ================================================ FILE: beetsplug/bpsync.py ================================================ # This file is part of beets. # Copyright 2019, Rahul Ahuja. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Update library's tags using Beatport.""" from beets import autotag, library, ui, util from beets.plugins import BeetsPlugin, apply_item_changes from beets.util.deprecation import deprecate_for_user from .beatport import BeatportPlugin class BPSyncPlugin(BeetsPlugin): def __init__(self): super().__init__() deprecate_for_user(self._log, "The 'bpsync' plugin") self.beatport_plugin = BeatportPlugin() self.beatport_plugin.setup() def commands(self): cmd = ui.Subcommand("bpsync", help="update metadata from Beatport") cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show all changes but do nothing", ) cmd.parser.add_option( "-m", "--move", action="store_true", dest="move", help="move files in the library directory", ) cmd.parser.add_option( "-M", "--nomove", action="store_false", dest="move", help="don't move files in library", ) cmd.parser.add_option( "-W", "--nowrite", action="store_false", default=None, dest="write", help="don't write updated metadata to files", ) cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the bpsync function.""" move = ui.should_move(opts.move) pretend = opts.pretend write = ui.should_write(opts.write) self.singletons(lib, args, move, pretend, write) self.albums(lib, args, move, pretend, write) def singletons(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for items matched by query. """ for item in lib.items([*query, "singleton:true"]): if not item.mb_trackid: self._log.info( "Skipping singleton with no mb_trackid: {}", item ) continue if not self.is_beatport_track(item): self._log.info( "Skipping non-{.beatport_plugin.data_source} singleton: {}", self, item, ) continue # Apply. trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid) with lib.transaction(): autotag.apply_item_metadata(item, trackinfo) apply_item_changes(lib, item, move, pretend, write) @staticmethod def is_beatport_track(item): return ( item.get("data_source") == BeatportPlugin.data_source and item.mb_trackid.isnumeric() ) def get_album_tracks(self, album): if not album.mb_albumid: self._log.info("Skipping album with no mb_albumid: {}", album) return False if not album.mb_albumid.isnumeric(): self._log.info( "Skipping album with invalid {.beatport_plugin.data_source} ID: {}", self, album, ) return False items = list(album.items()) if album.get("data_source") == self.beatport_plugin.data_source: return items if not all(self.is_beatport_track(item) for item in items): self._log.info( "Skipping non-{.beatport_plugin.data_source} release: {}", self, album, ) return False return items def albums(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for albums matched by query and their items. """ # Process matching albums. for album in lib.albums(query): # Do we have a valid Beatport album? items = self.get_album_tracks(album) if not items: continue # Get the Beatport album information. albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid) if not albuminfo: self._log.info( "Release ID {0.mb_albumid} not found for album {0}", album ) continue beatport_trackid_to_trackinfo = { track.track_id: track for track in albuminfo.tracks } library_trackid_to_item = { int(item.mb_trackid): item for item in items } item_info_pairs = [ (item, beatport_trackid_to_trackinfo[track_id]) for track_id, item in library_trackid_to_item.items() ] self._log.info("applying changes to {}", album) with lib.transaction(): autotag.apply_metadata(albuminfo, item_info_pairs) changed = False # Find any changed item to apply Beatport changes to album. any_changed_item = items[0] for item in items: item_changed = ui.show_model_changes(item) changed |= item_changed if item_changed: any_changed_item = item apply_item_changes(lib, item, move, pretend, write) if pretend or not changed: continue # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = any_changed_item[key] album.store() # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): self._log.debug("moving album {}", album) album.move() ================================================ FILE: beetsplug/bucket.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides the %bucket{} function for path formatting.""" import re import string from datetime import datetime from itertools import tee from beets import plugins, ui ASCII_DIGITS = string.digits + string.ascii_lowercase class BucketError(Exception): pass def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) return zip(a, b) def span_from_str(span_str): """Build a span dict from the span string representation.""" def normalize_year(d, yearfrom): """Convert string to a 4 digits year""" if yearfrom < 100: raise BucketError(f"{yearfrom} must be expressed on 4 digits") # if two digits only, pick closest year that ends by these two # digits starting from yearfrom if d < 100: if (d % 100) < (yearfrom % 100): d = (yearfrom - yearfrom % 100) + 100 + d else: d = (yearfrom - yearfrom % 100) + d return d years = [int(x) for x in re.findall(r"\d+", span_str)] if not years: raise ui.UserError( f"invalid range defined for year bucket {span_str!r}: no year found" ) try: years = [normalize_year(x, years[0]) for x in years] except BucketError as exc: raise ui.UserError( f"invalid range defined for year bucket {span_str!r}: {exc}" ) res = {"from": years[0], "str": span_str} if len(years) > 1: res["to"] = years[-1] return res def complete_year_spans(spans): """Set the `to` value of spans if empty and sort them chronologically.""" spans.sort(key=lambda x: x["from"]) for x, y in pairwise(spans): if "to" not in x: x["to"] = y["from"] - 1 if spans and "to" not in spans[-1]: spans[-1]["to"] = datetime.now().year def extend_year_spans(spans, spanlen, start=1900, end=2014): """Add new spans to given spans list so that every year of [start,end] belongs to a span. """ extended_spans = spans[:] for x, y in pairwise(spans): # if a gap between two spans, fill the gap with as much spans of # spanlen length as necessary for span_from in range(x["to"] + 1, y["from"], spanlen): extended_spans.append({"from": span_from}) # Create spans prior to declared ones for span_from in range(spans[0]["from"] - spanlen, start, -spanlen): extended_spans.append({"from": span_from}) # Create spans after the declared ones for span_from in range(spans[-1]["to"] + 1, end, spanlen): extended_spans.append({"from": span_from}) complete_year_spans(extended_spans) return extended_spans def build_year_spans(year_spans_str): """Build a chronologically ordered list of spans dict from unordered spans stringlist. """ spans = [] for elem in year_spans_str: spans.append(span_from_str(elem)) complete_year_spans(spans) return spans def str2fmt(s): """Deduces formatting syntax from a span string.""" regex = re.compile( r"(?P\D*)(?P\d+)(?P\D*)" r"(?P\d*)(?P\D*)" ) m = re.match(regex, s) res = { "fromnchars": len(m.group("fromyear")), "tonchars": len(m.group("toyear")), } res["fmt"] = ( f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}" ) return res def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): """Return a span string representation.""" args = [str(yearfrom)[-fromnchars:]] if tonchars: args.append(str(yearto)[-tonchars:]) return fmt.format(*args) def extract_modes(spans): """Extract the most common spans lengths and representation formats""" rangelen = sorted([x["to"] - x["from"] + 1 for x in spans]) deflen = sorted(rangelen, key=rangelen.count)[-1] reprs = [str2fmt(x["str"]) for x in spans] deffmt = sorted(reprs, key=reprs.count)[-1] return deflen, deffmt def build_alpha_spans(alpha_spans_str, alpha_regexs): """Extract alphanumerics from string and return sorted list of chars [from...to] """ spans = [] for elem in alpha_spans_str: if elem in alpha_regexs: spans.append(re.compile(alpha_regexs[elem])) else: bucket = sorted([x for x in elem.lower() if x.isalnum()]) if bucket: begin_index = ASCII_DIGITS.index(bucket[0]) end_index = ASCII_DIGITS.index(bucket[-1]) else: raise ui.UserError( "invalid range defined for alpha bucket " f"'{elem}': no alphanumeric character found" ) spans.append( re.compile( rf"^[{ASCII_DIGITS[begin_index : end_index + 1]}]", re.IGNORECASE, ) ) return spans class BucketPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.template_funcs["bucket"] = self._tmpl_bucket self.config.add( { "bucket_year": [], "bucket_alpha": [], "bucket_alpha_regex": {}, "extrapolate": False, } ) self.setup() def setup(self): """Setup plugin from config options""" self.year_spans = build_year_spans(self.config["bucket_year"].get()) if self.year_spans and self.config["extrapolate"]: [self.ys_len_mode, self.ys_repr_mode] = extract_modes( self.year_spans ) self.year_spans = extend_year_spans( self.year_spans, self.ys_len_mode ) self.alpha_spans = build_alpha_spans( self.config["bucket_alpha"].get(), self.config["bucket_alpha_regex"].get(), ) def find_bucket_year(self, year): """Return bucket that matches given year or return the year if no matching bucket. """ for ys in self.year_spans: if ys["from"] <= int(year) <= ys["to"]: if "str" in ys: return ys["str"] else: return format_span( self.ys_repr_mode["fmt"], ys["from"], ys["to"], self.ys_repr_mode["fromnchars"], self.ys_repr_mode["tonchars"], ) return year def find_bucket_alpha(self, s): """Return alpha-range bucket that matches given string or return the string initial if no matching bucket. """ for i, span in enumerate(self.alpha_spans): if span.match(s): return self.config["bucket_alpha"].get()[i] return s[0].upper() def _tmpl_bucket(self, text, field=None): if not field and len(text) == 4 and text.isdigit(): field = "year" if field == "year": func = self.find_bucket_year else: func = self.find_bucket_alpha return func(text) ================================================ FILE: beetsplug/chroma.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Chromaprint/Acoustid acoustic fingerprinting support to the autotagger. Requires the pyacoustid library. """ from __future__ import annotations import re from collections import defaultdict from functools import cached_property, partial from typing import TYPE_CHECKING import acoustid import confuse from beets import config, ui, util from beets.autotag.distance import Distance from beets.metadata_plugins import MetadataSourcePlugin from beetsplug.musicbrainz import MusicBrainzPlugin if TYPE_CHECKING: from collections.abc import Iterable from beets.autotag.hooks import TrackInfo API_KEY = "1vOwZtEn" SCORE_THRESH = 0.5 TRACK_ID_WEIGHT = 10.0 COMMON_REL_THRESH = 0.6 # How many tracks must have an album in common? MAX_RECORDINGS = 5 MAX_RELEASES = 5 # Stores the Acoustid match information for each track. This is # populated when an import task begins and then used when searching for # candidates. It maps audio file paths to (recording_ids, release_ids) # pairs. If a given path is not present in the mapping, then no match # was found. _matches = {} # Stores the fingerprint and Acoustid ID for each track. This is stored # as metadata for each track for later use but is not relevant for # autotagging. _fingerprints = {} _acoustids = {} def prefix(it, count): """Truncate an iterable to at most `count` items.""" for i, v in enumerate(it): if i >= count: break yield v def releases_key(release, countries, original_year): """Used as a key to sort releases by date then preferred country""" date = release.get("date") if date and original_year: year = date.get("year", 9999) month = date.get("month", 99) day = date.get("day", 99) else: year = 9999 month = 99 day = 99 # Uses index of preferred countries to sort country_key = 99 if release.get("country"): for i, country in enumerate(countries): if country.match(release["country"]): country_key = i break return (year, month, day, country_key) def acoustid_match(log, path): """Gets metadata for a file from Acoustid and populates the _matches, _fingerprints, and _acoustids dictionaries accordingly. """ try: duration, fp = acoustid.fingerprint_file(util.syspath(path)) except acoustid.FingerprintGenerationError as exc: log.error( "fingerprinting of {} failed: {}", util.displayable_path(repr(path)), exc, ) return None fp = fp.decode() _fingerprints[path] = fp try: res = acoustid.lookup( API_KEY, fp, duration, meta="recordings releases", timeout=10 ) except acoustid.AcoustidError as exc: log.debug( "fingerprint matching {} failed: {}", util.displayable_path(repr(path)), exc, ) return None log.debug("chroma: fingerprinted {}", util.displayable_path(repr(path))) # Ensure the response is usable and parse it. if res["status"] != "ok" or not res.get("results"): log.debug("no match found") return None result = res["results"][0] # Best match. if result["score"] < SCORE_THRESH: log.debug("no results above threshold") return None _acoustids[path] = result["id"] # Get recording and releases from the result if not result.get("recordings"): log.debug("no recordings found") return None recording_ids = [] releases = [] for recording in result["recordings"]: recording_ids.append(recording["id"]) if "releases" in recording: releases.extend(recording["releases"]) # The releases list is essentially in random order from the Acoustid lookup # so we optionally sort it using the match.preferred configuration options. # 'original_year' to sort the earliest first and # 'countries' to then sort preferred countries first. country_patterns = config["match"]["preferred"]["countries"].as_str_seq() countries = [re.compile(pat, re.I) for pat in country_patterns] original_year = config["match"]["preferred"]["original_year"] releases.sort( key=partial( releases_key, countries=countries, original_year=original_year ) ) release_ids = [rel["id"] for rel in releases] log.debug( "matched recordings {} on releases {}", recording_ids, release_ids ) _matches[path] = recording_ids, release_ids # Plugin structure and autotagging logic. def _all_releases(items): """Given an iterable of Items, determines (according to Acoustid) which releases the items have in common. Generates release IDs. """ # Count the number of "hits" for each release. relcounts = defaultdict(int) for item in items: if item.path not in _matches: continue _, release_ids = _matches[item.path] for release_id in release_ids: relcounts[release_id] += 1 for release_id, count in relcounts.items(): if float(count) / len(items) > COMMON_REL_THRESH: yield release_id class AcoustidPlugin(MetadataSourcePlugin): def __init__(self): super().__init__() self.config.add( { "auto": True, } ) config["acoustid"]["apikey"].redact = True if self.config["auto"]: self.register_listener("import_task_start", self.fingerprint_task) self.register_listener("import_task_apply", apply_acoustid_metadata) @cached_property def mb(self) -> MusicBrainzPlugin: return MusicBrainzPlugin() def fingerprint_task(self, task, session): return fingerprint_task(self._log, task, session) def track_distance(self, item, info): dist = Distance() if item.path not in _matches or not info.track_id: # Match failed or no track ID. return dist recording_ids, _ = _matches[item.path] dist.add_expr("track_id", info.track_id not in recording_ids) return dist def candidates(self, items, artist, album, va_likely): albums = [] for relid in prefix(_all_releases(items), MAX_RELEASES): album = self.mb.album_for_id(relid) if album: albums.append(album) self._log.debug("acoustid album candidates: {}", len(albums)) return albums def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]: if item.path not in _matches: return [] recording_ids, _ = _matches[item.path] tracks = [] for recording_id in prefix(recording_ids, MAX_RECORDINGS): track = self.mb.track_for_id(recording_id) if track: tracks.append(track) self._log.debug("acoustid item candidates: {}", len(tracks)) return tracks def album_for_id(self, *args, **kwargs): # Lookup by fingerprint ID does not make too much sense. return None def track_for_id(self, *args, **kwargs): # Lookup by fingerprint ID does not make too much sense. return None def commands(self): submit_cmd = ui.Subcommand( "submit", help="submit Acoustid fingerprints" ) def submit_cmd_func(lib, opts, args): try: apikey = config["acoustid"]["apikey"].as_str() except confuse.NotFoundError: raise ui.UserError("no Acoustid user API key provided") submit_items(self._log, apikey, lib.items(args)) submit_cmd.func = submit_cmd_func fingerprint_cmd = ui.Subcommand( "fingerprint", help="generate fingerprints for items without them" ) def fingerprint_cmd_func(lib, opts, args): for item in lib.items(args): fingerprint_item(self._log, item, write=ui.should_write()) fingerprint_cmd.func = fingerprint_cmd_func return [submit_cmd, fingerprint_cmd] # Hooks into import process. def fingerprint_task(log, task, session): """Fingerprint each item in the task for later use during the autotagging candidate search. """ items = task.items if task.is_album else [task.item] for item in items: acoustid_match(log, item.path) def apply_acoustid_metadata(task, session): """Apply Acoustid metadata (fingerprint and ID) to the task's items.""" for item in task.imported_items(): if item.path in _fingerprints: item.acoustid_fingerprint = _fingerprints[item.path] if item.path in _acoustids: item.acoustid_id = _acoustids[item.path] # UI commands. def submit_items(log, userkey, items, chunksize=64): """Submit fingerprints for the items to the Acoustid server.""" data = [] # The running list of dictionaries to submit. def submit_chunk(): """Submit the current accumulated fingerprint data.""" log.info("submitting {} fingerprints", len(data)) try: acoustid.submit(API_KEY, userkey, data, timeout=10) except acoustid.AcoustidError as exc: log.warning("acoustid submission error: {}", exc) del data[:] for item in items: fp = fingerprint_item(log, item, write=ui.should_write()) # Construct a submission dictionary for this item. item_data = { "duration": int(item.length), "fingerprint": fp, } if item.mb_trackid: item_data["mbid"] = item.mb_trackid log.debug("submitting MBID") else: item_data.update( { "track": item.title, "artist": item.artist, "album": item.album, "albumartist": item.albumartist, "year": item.year, "trackno": item.track, "discno": item.disc, } ) log.debug("submitting textual metadata") data.append(item_data) # If we have enough data, submit a chunk. if len(data) >= chunksize: submit_chunk() # Submit remaining data in a final chunk. if data: submit_chunk() def fingerprint_item(log, item, write=False): """Get the fingerprint for an Item. If the item already has a fingerprint, it is not regenerated. If fingerprint generation fails, return None. If the items are associated with a library, they are saved to the database. If `write` is set, then the new fingerprints are also written to files' metadata. """ # Get a fingerprint and length for this track. if not item.length: log.info("{.filepath}: no duration available", item) elif item.acoustid_fingerprint: if write: log.info("{.filepath}: fingerprint exists, skipping", item) else: log.info("{.filepath}: using existing fingerprint", item) return item.acoustid_fingerprint else: log.info("{.filepath}: fingerprinting", item) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp.decode() if write: log.info("{.filepath}: writing fingerprint", item) item.try_write() if item._db: item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info("fingerprint generation failed: {}", exc) ================================================ FILE: beetsplug/convert.py ================================================ # This file is part of beets. # Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Converts tracks or albums to external directory""" import logging import os import shlex import subprocess import tempfile import threading from string import Template import mediafile from confuse import ConfigTypeError, Optional from beets import config, plugins, ui, util from beets.library import Item, parse_query_string from beets.plugins import BeetsPlugin from beets.util import par_map from beets.util.artresizer import ArtResizer from beets.util.m3u import M3UFile from beetsplug._utils import art _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. # Some convenient alternate names for formats. ALIASES = { "windows media": "wma", "vorbis": "ogg", } LOSSLESS_FORMATS = ["ape", "flac", "alac", "wave", "aiff"] def replace_ext(path, ext): """Return the path with its extension replaced by `ext`. The new extension must not contain a leading dot. """ ext_dot = b"." + ext return os.path.splitext(path)[0] + ext_dot def get_format(fmt=None): """Return the command template and the extension from the config.""" if not fmt: fmt = config["convert"]["format"].as_str().lower() fmt = ALIASES.get(fmt, fmt) try: format_info = config["convert"]["formats"][fmt].get(dict) command = format_info["command"] extension = format_info.get("extension", fmt) except KeyError: raise ui.UserError(f'convert: format {fmt} needs the "command" field') except ConfigTypeError: command = config["convert"]["formats"][fmt].get(str) extension = fmt # Convenience and backwards-compatibility shortcuts. keys = config["convert"].keys() if "command" in keys: command = config["convert"]["command"].as_str() elif "opts" in keys: # Undocumented option for backwards compatibility with < 1.3.1. command = ( f"ffmpeg -i $source -y {config['convert']['opts'].as_str()} $dest" ) if "extension" in keys: extension = config["convert"]["extension"].as_str() return (command.encode("utf-8"), extension.encode("utf-8")) def in_no_convert(item: Item) -> bool: no_convert_query = config["convert"]["no_convert"].as_str() if no_convert_query: query, _ = parse_query_string(no_convert_query, Item) return query.match(item) else: return False def should_transcode(item, fmt, force: bool = False): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). If ``force`` is True, safety checks like ``no_convert`` and ``never_convert_lossy_files`` are ignored and the item is always transcoded. """ if force: return True if in_no_convert(item) or ( config["convert"]["never_convert_lossy_files"].get(bool) and item.format.lower() not in LOSSLESS_FORMATS ): return False maxbr = config["convert"]["max_bitrate"].get(Optional(int)) if maxbr is not None and item.bitrate >= 1000 * maxbr: return True return fmt.lower() != item.format.lower() class ConvertPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "dest": None, "pretend": False, "link": False, "hardlink": False, "threads": os.cpu_count(), "format": "mp3", "id3v23": "inherit", "write_metadata": True, "formats": { "aac": { "command": ( "ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest" ), "extension": "m4a", }, "alac": { "command": ( "ffmpeg -i $source -y -vn -acodec alac $dest" ), "extension": "m4a", }, "flac": "ffmpeg -i $source -y -vn -acodec flac $dest", "mp3": "ffmpeg -i $source -y -vn -aq 2 $dest", "opus": ( "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest" ), "ogg": ( "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest" ), "wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest", }, "max_bitrate": None, "auto": False, "auto_keep": False, "tmpdir": None, "quiet": False, "embed": True, "paths": {}, "no_convert": "", "never_convert_lossy_files": False, "copy_album_art": False, "album_art_maxwidth": 0, "delete_originals": False, "playlist": None, } ) self.early_import_stages = [self.auto_convert, self.auto_convert_keep] self.register_listener("import_task_files", self._cleanup) def commands(self): cmd = ui.Subcommand("convert", help="convert to external location") cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show actions but do nothing", ) cmd.parser.add_option( "-t", "--threads", action="store", type="int", help=( "change the number of threads, defaults to maximum available" " processors" ), ) cmd.parser.add_option( "-k", "--keep-new", action="store_true", dest="keep_new", help="keep only the converted and move the old files", ) cmd.parser.add_option( "-d", "--dest", action="store", help="set the destination directory" ) cmd.parser.add_option( "-f", "--format", action="store", dest="format", help="set the target format of the tracks", ) cmd.parser.add_option( "-y", "--yes", action="store_true", dest="yes", help="do not ask for confirmation", ) cmd.parser.add_option( "-l", "--link", action="store_true", dest="link", help="symlink files that do not need transcoding.", ) cmd.parser.add_option( "-H", "--hardlink", action="store_true", dest="hardlink", help=( "hardlink files that do not need transcoding. Overrides --link." ), ) cmd.parser.add_option( "-m", "--playlist", action="store", help="""create an m3u8 playlist file containing the converted files. The playlist file will be saved below the destination directory, thus PLAYLIST could be a file name or a relative path. To ensure a working playlist when transferred to a different computer, or opened from an external drive, relative paths pointing to media files will be used.""", ) cmd.parser.add_option( "-F", "--force", action="store_true", dest="force", help=( "force transcoding. Ignores no_convert, " "never_convert_lossy_files, and max_bitrate" ), ) cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] def auto_convert(self, config, task): if self.config["auto"]: par_map( lambda item: self.convert_on_import(config.lib, item), task.imported_items(), ) def auto_convert_keep(self, config, task): if self.config["auto_keep"]: empty_opts = self.commands()[0].parser.get_default_values() ( dest, threads, path_formats, fmt, pretend, hardlink, link, _, force, ) = self._get_opts_and_config(empty_opts) items = task.imported_items() # Filter items based on should_transcode function items = [item for item in items if should_transcode(item, fmt)] self._parallel_convert( dest, False, path_formats, fmt, pretend, link, hardlink, threads, items, force, ) # Utilities converted from functions to methods on logging overhaul def encode(self, command, source, dest, pretend=False): """Encode `source` to `dest` using command template `command`. Raises `subprocess.CalledProcessError` if the command exited with a non-zero status code. """ # The paths and arguments must be bytes. assert isinstance(command, bytes) assert isinstance(source, bytes) assert isinstance(dest, bytes) quiet = self.config["quiet"].get(bool) if not quiet and not pretend: self._log.info("Encoding {}", util.displayable_path(source)) command = os.fsdecode(command) source = os.fsdecode(source) dest = os.fsdecode(dest) # Substitute $source and $dest in the argument list. args = shlex.split(command) encode_cmd = [] for i, arg in enumerate(args): args[i] = Template(arg).safe_substitute( { "source": source, "dest": dest, } ) encode_cmd.append(os.fsdecode(args[i])) if pretend: self._log.info("{}", " ".join(args)) return try: util.command_output(encode_cmd) except subprocess.CalledProcessError as exc: # Something went wrong (probably Ctrl+C), remove temporary files self._log.info( "Encoding {} failed. Cleaning up...", util.displayable_path(source), ) self._log.debug( "Command {0} exited with status {1.returncode}: {1.output}", args, exc, ) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) raise except OSError as exc: raise ui.UserError( f"convert: couldn't invoke {' '.join(args)!r}: {exc}" ) if not quiet and not pretend: self._log.info( "Finished encoding {}", util.displayable_path(source) ) def convert_item( self, dest_dir, keep_new, path_formats, fmt, pretend=False, link=False, hardlink=False, force=False, ): """A pipeline thread that converts `Item` objects from a library. """ command, ext = get_format(fmt) item, original, converted = None, None, None while True: item = yield (item, original, converted) dest = item.destination(basedir=dest_dir, path_formats=path_formats) # Ensure that desired item is readable before processing it. Needed # to avoid any side-effect of the conversion (linking, keep_new, # refresh) if we already know that it will fail. try: mediafile.MediaFile(util.syspath(item.path)) except mediafile.UnreadableFileError as exc: self._log.error("Could not open file to convert: {}", exc) continue # When keeping the new file in the library, we first move the # current (pristine) file to the destination. We'll then copy it # back to its old path or transcode it to a new path. if keep_new: original = dest converted = item.path if should_transcode(item, fmt, force): converted = replace_ext(converted, ext) else: original = item.path if should_transcode(item, fmt, force): dest = replace_ext(dest, ext) converted = dest # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory # creation inside this function.) if not pretend: with _fs_lock: util.mkdirall(dest) if os.path.exists(util.syspath(dest)): self._log.info( "Skipping {.filepath} (target file exists)", item ) continue if keep_new: if pretend: self._log.info( "mv {.filepath} {}", item, util.displayable_path(original), ) else: self._log.info( "Moving to {}", util.displayable_path(original) ) util.move(item.path, original) if should_transcode(item, fmt, force): linked = False try: self.encode(command, original, converted, pretend) except subprocess.CalledProcessError: continue else: linked = link or hardlink if pretend: msg = "ln" if hardlink else ("ln -s" if link else "cp") self._log.info( "{} {} {}", msg, util.displayable_path(original), util.displayable_path(converted), ) else: # No transcoding necessary. msg = ( "Hardlinking" if hardlink else ("Linking" if link else "Copying") ) self._log.info("{} {.filepath}", msg, item) if hardlink: util.hardlink(original, converted) elif link: util.link(original, converted) else: util.copy(original, converted) if pretend: continue id3v23 = self.config["id3v23"].as_choice([True, False, "inherit"]) if id3v23 == "inherit": id3v23 = None # Write tags from the database to the file if requested if self.config["write_metadata"].get(bool): item.try_write(path=converted, id3v23=id3v23) if keep_new: # If we're keeping the transcoded file, read it again (after # writing) to get new bitrate, duration, etc. item.path = converted item.read() item.store() # Store new path and audio data. if self.config["embed"] and not linked: album = item._cached_album if album and album.artpath: maxwidth = self._get_art_resize(album.artpath) self._log.debug( "embedding album art from {.art_filepath}", album ) art.embed_item( self._log, item, album.artpath, maxwidth, itempath=converted, id3v23=id3v23, ) if keep_new: plugins.send( "after_convert", item=item, dest=dest, keepnew=True ) else: plugins.send( "after_convert", item=item, dest=converted, keepnew=False ) def copy_album_art( self, album, dest_dir, path_formats, pretend=False, link=False, hardlink=False, ): """Copies or converts the associated cover art of the album. Album must have at least one track. """ if not album or not album.artpath: return album_item = album.items().get() # Album shouldn't be empty. if not album_item: return # Get the destination of the first item (track) of the album, we use # this function to format the path accordingly to path_formats. dest = album_item.destination( basedir=dest_dir, path_formats=path_formats ) # Remove item from the path. dest = os.path.join(*util.components(dest)[:-1]) dest = album.art_destination(album.artpath, item_dir=dest) if album.artpath == dest: return if not pretend: util.mkdirall(dest) if os.path.exists(util.syspath(dest)): self._log.info( "Skipping {.art_filepath} (target file exists)", album ) return # Decide whether we need to resize the cover-art image. maxwidth = self._get_art_resize(album.artpath) # Either copy or resize (while copying) the image. if maxwidth is not None: self._log.info( "Resizing cover art from {.art_filepath} to {}", album, util.displayable_path(dest), ) if not pretend: ArtResizer.shared.resize(maxwidth, album.artpath, dest) else: if pretend: msg = "ln" if hardlink else ("ln -s" if link else "cp") self._log.info( "{} {.art_filepath} {}", msg, album, util.displayable_path(dest), ) else: msg = ( "Hardlinking" if hardlink else ("Linking" if link else "Copying") ) self._log.info( "{} cover art from {.art_filepath} to {}", msg, album, util.displayable_path(dest), ) if hardlink: util.hardlink(album.artpath, dest) elif link: util.link(album.artpath, dest) else: util.copy(album.artpath, dest) def convert_func(self, lib, opts, args): ( dest, threads, path_formats, fmt, pretend, hardlink, link, playlist, force, ) = self._get_opts_and_config(opts) if opts.album: albums = lib.albums(args) items = [i for a in albums for i in a.items()] if not pretend: for a in albums: ui.print_(format(a, "")) else: items = list(lib.items(args)) if not pretend: for i in items: ui.print_(format(i, "")) if not items: self._log.error("Empty query result.") return if not (pretend or opts.yes or ui.input_yn("Convert? (Y/n)")): return if opts.album and self.config["copy_album_art"]: for album in albums: self.copy_album_art( album, dest, path_formats, pretend, link, hardlink ) # If the user supplied a playlist name, create a playlist for files # copied to the destination. pl_normpath = None items_paths = None if playlist: _, ext = get_format(fmt) # Playlist paths are understood as relative to the dest directory. pl_normpath = util.normpath(playlist) pl_dir = os.path.dirname(pl_normpath) items_paths = [] for item in items: item_path = item.destination( basedir=dest, path_formats=path_formats ) # When keeping new files in the library, destination paths # keep original files and extensions. if not opts.keep_new and should_transcode(item, fmt, force): item_path = replace_ext(item_path, ext) items_paths.append(os.path.relpath(item_path, pl_dir)) self._parallel_convert( dest, opts.keep_new, path_formats, fmt, pretend, link, hardlink, threads, items, force, ) if playlist: self._log.info("Creating playlist file {}", pl_normpath) if not pretend: m3ufile = M3UFile(playlist) m3ufile.set_contents(items_paths) m3ufile.write() def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the library. """ fmt = self.config["format"].as_str().lower() if should_transcode(item, fmt): command, ext = get_format() # Create a temporary file for the conversion. tmpdir = self.config["tmpdir"].get() if tmpdir: tmpdir = os.fsdecode(util.bytestring_path(tmpdir)) fd, dest = tempfile.mkstemp(f".{os.fsdecode(ext)}", dir=tmpdir) os.close(fd) dest = util.bytestring_path(dest) _temp_files.append(dest) # Delete the transcode later. # Convert. try: self.encode(command, item.path, dest) except subprocess.CalledProcessError: return # Change the newly-imported database entry to point to the # converted file. source_path = item.path item.path = dest item.write() item.read() # Load new audio information data. item.store() if self.config["delete_originals"]: self._log.log( logging.DEBUG if self.config["quiet"] else logging.INFO, "Removing original file {}", source_path, ) util.remove(source_path, False) def _get_art_resize(self, artpath): """For a given piece of album art, determine whether or not it needs to be resized according to the user's settings. If so, returns the new size. If not, returns None. """ newwidth = None if self.config["album_art_maxwidth"]: maxwidth = self.config["album_art_maxwidth"].get(int) size = ArtResizer.shared.get_size(artpath) self._log.debug("image size: {}", size) if size: if size[0] > maxwidth: newwidth = maxwidth else: self._log.warning( "Could not get size of image (please see " "documentation for dependencies)." ) return newwidth def _cleanup(self, task, session): for path in task.old_paths: if path in _temp_files: if os.path.isfile(util.syspath(path)): util.remove(path) _temp_files.remove(path) def _get_opts_and_config(self, opts): """Returns parameters needed for convert function. Get parameters from command line if available, default to config if not available. """ dest = opts.dest or self.config["dest"].get() if not dest: raise ui.UserError("no convert destination set") dest = util.bytestring_path(dest) threads = opts.threads or self.config["threads"].get(int) path_formats = ui.get_path_formats(self.config["paths"] or None) fmt = opts.format or self.config["format"].as_str().lower() playlist = opts.playlist or self.config["playlist"].get() if playlist is not None: playlist = os.path.join(dest, util.bytestring_path(playlist)) if opts.pretend is not None: pretend = opts.pretend else: pretend = self.config["pretend"].get(bool) if opts.hardlink is not None: hardlink = opts.hardlink link = False elif opts.link is not None: hardlink = False link = opts.link else: hardlink = self.config["hardlink"].get(bool) link = self.config["link"].get(bool) force = getattr(opts, "force", False) return ( dest, threads, path_formats, fmt, pretend, hardlink, link, playlist, force, ) def _parallel_convert( self, dest, keep_new, path_formats, fmt, pretend, link, hardlink, threads, items, force, ): """Run the convert_item function for every items on as many thread as defined in threads """ convert = [ self.convert_item( dest, keep_new, path_formats, fmt, pretend, link, hardlink, force, ) for _ in range(threads) ] pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() ================================================ FILE: beetsplug/deezer.py ================================================ # This file is part of beets. # Copyright 2019, Rahul Ahuja. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Deezer release and track search support to the autotagger""" from __future__ import annotations import collections import time from typing import TYPE_CHECKING, ClassVar import requests from beets import ui from beets.autotag import AlbumInfo, TrackInfo from beets.dbcore import types from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Item, Library from beets.metadata_plugins import QueryType, SearchParams from ._typing import JSONDict class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): item_types: ClassVar[dict[str, types.Type]] = { "deezer_track_rank": types.INTEGER, "deezer_track_id": types.INTEGER, "deezer_updated": types.DATE, } # Base URLs for the Deezer API # Documentation: https://developers.deezer.com/api/ search_url = "https://api.deezer.com/search/" album_url = "https://api.deezer.com/album/" track_url = "https://api.deezer.com/track/" def __init__(self) -> None: super().__init__() def commands(self): """Add beet UI commands to interact with Deezer.""" deezer_update_cmd = ui.Subcommand( "deezerupdate", help=f"Update {self.data_source} rank" ) def func(lib: Library, opts, args): items = lib.items(args) self.deezerupdate(list(items), ui.should_write()) deezer_update_cmd.func = func return [deezer_update_cmd] def album_for_id(self, album_id: str) -> AlbumInfo | None: """Fetch an album by its Deezer ID or URL.""" if not (deezer_id := self._extract_id(album_id)): return None album_url = f"{self.album_url}{deezer_id}" if not (album_data := self.fetch_data(album_url)): return None contributors = album_data.get("contributors") if contributors is not None: artist, artist_id = self.get_artist(contributors) else: artist, artist_id = None, None release_date = album_data["release_date"] date_parts = [int(part) for part in release_date.split("-")] num_date_parts = len(date_parts) if num_date_parts == 3: year, month, day = date_parts elif num_date_parts == 2: year, month = date_parts day = None elif num_date_parts == 1: year = date_parts[0] month = None day = None else: raise ui.UserError( f"Invalid `release_date` returned by {self.data_source} API: " f"{release_date!r}" ) tracks_obj = self.fetch_data(f"{self.album_url}{deezer_id}/tracks") if tracks_obj is None: return None try: tracks_data = tracks_obj["data"] except KeyError: self._log.debug("Error fetching album tracks for {}", deezer_id) tracks_data = None if not tracks_data: return None while "next" in tracks_obj: tracks_obj = requests.get( tracks_obj["next"], timeout=10, ).json() tracks_data.extend(tracks_obj["data"]) tracks = [] medium_totals: dict[int | None, int] = collections.defaultdict(int) for i, track_data in enumerate(tracks_data, start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: track.medium_total = medium_totals[track.medium] return AlbumInfo( album=album_data["title"], album_id=deezer_id, deezer_album_id=deezer_id, artist=artist, artist_credit=self.get_artist([album_data["artist"]])[0], artist_id=artist_id, tracks=tracks, albumtype=album_data["record_type"], va=( len(album_data["contributors"]) == 1 and (artist or "").lower() == "various artists" ), year=year, month=month, day=day, label=album_data["label"], mediums=max(filter(None, medium_totals.keys())), data_source=self.data_source, data_url=album_data["link"], cover_art_url=album_data.get("cover_xl"), ) def track_for_id(self, track_id: str) -> None | TrackInfo: """Fetch a track by its Deezer ID or URL and return a TrackInfo object or None if the track is not found. :param track_id: (Optional) Deezer ID or URL for the track. Either ``track_id`` or ``track_data`` must be provided. """ if not (deezer_id := self._extract_id(track_id)): self._log.debug("Invalid Deezer track_id: {}", track_id) return None if not (track_data := self.fetch_data(f"{self.track_url}{deezer_id}")): self._log.debug("Track not found: {}", track_id) return None track = self._get_track(track_data) # Get album's tracks to set `track.index` (position on the entire # release) and `track.medium_total` (total number of tracks on # the track's disc). if not ( album_tracks_obj := self.fetch_data( f"{self.album_url}{track_data['album']['id']}/tracks" ) ): return None try: album_tracks_data = album_tracks_obj["data"] except KeyError: self._log.debug( "Error fetching album tracks for {}", track_data["album"]["id"] ) return None medium_total = 0 for i, track_data in enumerate(album_tracks_data, start=1): if track_data["disk_number"] == track.medium: medium_total += 1 if track_data["id"] == track.track_id: track.index = i track.medium_total = medium_total return track def _get_track(self, track_data: JSONDict) -> TrackInfo: """Convert a Deezer track object dict to a TrackInfo object. :param track_data: Deezer Track object dict """ artist, artist_id = self.get_artist( track_data.get("contributors", [track_data["artist"]]) ) return TrackInfo( title=track_data["title"], track_id=track_data["id"], deezer_track_id=track_data["id"], isrc=track_data.get("isrc"), artist=artist, artist_id=artist_id, length=track_data["duration"], index=track_data.get("track_position"), medium=track_data.get("disk_number"), deezer_track_rank=track_data.get("rank"), medium_index=track_data.get("track_position"), data_source=self.data_source, data_url=track_data["link"], deezer_updated=time.time(), ) def get_search_query_with_filters( self, query_type: QueryType, items: Sequence[Item], artist: str, name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: query = f'album:"{name}"' if query_type == "album" else name if query_type == "track" or not va_likely: query += f' artist:"{artist}"' return query, {} def get_search_response(self, params: SearchParams) -> list[IDResponse]: """Search Deezer and return the raw result payload entries.""" response = requests.get( f"{self.search_url}{params.query_type}", params={ **params.filters, "q": params.query, "limit": str(params.limit), }, timeout=10, ) response.raise_for_status() return response.json()["data"] def deezerupdate(self, items: Sequence[Item], write: bool): """Obtain rank information from Deezer.""" for index, item in enumerate(items, start=1): self._log.info( "Processing {}/{} tracks - {} ", index, len(items), item ) try: deezer_track_id = item.deezer_track_id except AttributeError: self._log.debug("No deezer_track_id present for: {}", item) continue try: rank = self.fetch_data( f"{self.track_url}{deezer_track_id}" ).get("rank") self._log.debug( "Deezer track: {} has {} rank", deezer_track_id, rank ) except Exception as e: self._log.debug("Invalid Deezer track_id: {}", e) continue item.deezer_track_rank = int(rank) item.store() item.deezer_updated = time.time() if write: item.try_write() def fetch_data(self, url: str): try: response = requests.get(url, timeout=10) response.raise_for_status() data = response.json() except requests.exceptions.RequestException as e: self._log.error("Error fetching data from {}\n Error: {}", url, e) return None if "error" in data: self._log.debug("Deezer API error: {}", data["error"]["message"]) return None return data ================================================ FILE: beetsplug/discogs/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Discogs album search support to the autotagger. Requires the python3-discogs-client library. """ from __future__ import annotations import http.client import json import os import re import socket import time import traceback from functools import cache, cached_property from string import ascii_lowercase from typing import TYPE_CHECKING import confuse from discogs_client import Client, Master, Release from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError import beets import beets.ui from beets import config, util from beets.autotag.distance import string_dist from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin from .states import DISAMBIGUATION_RE, ArtistState, TracklistState if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence from beets.library import Item from beets.metadata_plugins import QueryType, SearchParams from .types import ReleaseFormat, Track USER_AGENT = f"beets/{beets.__version__} +https://beets.io/" API_KEY = "rAzVUQYRaoFjeBjyWuWZ" API_SECRET = "plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy" # Exceptions that discogs_client should really handle but does not. CONNECTION_ERRORS = ( ConnectionError, socket.error, http.client.HTTPException, ValueError, # JSON decoding raises a ValueError. DiscogsAPIError, ) TRACK_INDEX_RE = re.compile( r""" (.*?) # medium: everything before medium_index. (\d*?) # medium_index: a number at the end of # `position`, except if followed by a subtrack index. # subtrack_index: can only be matched if medium # or medium_index have been matched, and can be ( (?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A) | (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a) )? """, re.VERBOSE, ) FIELDS_TO_DISCOGS_KEYS = { "barcode": "barcode", "catalognum": "catno", "country": "country", "label": "label", "media": "format", "year": "year", } class DiscogsPlugin(SearchApiMetadataSourcePlugin[IDResponse]): def __init__(self): super().__init__() self.config.add( { "apikey": API_KEY, "apisecret": API_SECRET, "tokenfile": "discogs_token.json", "user_token": "", "separator": ", ", "index_tracks": False, "append_style_genre": False, "strip_disambiguation": True, "featured_string": "Feat.", "extra_tags": [], "anv": { "artist_credit": True, "artist": False, "album_artist": False, }, } ) self.config["apikey"].redact = True self.config["apisecret"].redact = True self.config["user_token"].redact = True self.setup() @cached_property def extra_discogs_field_by_tag(self) -> dict[str, str]: """Map configured extra tags to Discogs API search parameters. Process user configuration to determine which additional Discogs fields should be included in search queries. """ field_by_tag = { tag: FIELDS_TO_DISCOGS_KEYS[tag] for tag in self.config["extra_tags"].as_str_seq() if tag in FIELDS_TO_DISCOGS_KEYS } if field_by_tag: self._log.debug( "Discogs additional search filters from tags: {}", field_by_tag ) return field_by_tag def setup(self, session=None) -> None: """Create the `discogs_client` field. Authenticate if necessary.""" c_key = self.config["apikey"].as_str() c_secret = self.config["apisecret"].as_str() # Try using a configured user token (bypassing OAuth login). user_token = self.config["user_token"].as_str() if user_token: # The rate limit for authenticated users goes up to 60 # requests per minute. self.discogs_client = Client(USER_AGENT, user_token=user_token) return # Get the OAuth token from a file or log in. try: with open(self._tokenfile()) as f: tokendata = json.load(f) except OSError: # No token yet. Generate one. token, secret = self.authenticate(c_key, c_secret) else: token = tokendata["token"] secret = tokendata["secret"] self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) def reset_auth(self) -> None: """Delete token file & redo the auth steps.""" os.remove(self._tokenfile()) self.setup() def _tokenfile(self) -> str: """Get the path to the JSON file for storing the OAuth token.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]: # Get the link for the OAuth page. auth_client = Client(USER_AGENT, c_key, c_secret) try: _, _, url = auth_client.get_authorize_url() except CONNECTION_ERRORS as e: self._log.debug("connection error: {}", e) raise beets.ui.UserError("communication with Discogs failed") beets.ui.print_("To authenticate with Discogs, visit:") beets.ui.print_(url) # Ask for the code and validate it. code = beets.ui.input_("Enter the code:") try: token, secret = auth_client.get_access_token(code) except DiscogsAPIError: raise beets.ui.UserError("Discogs authorization failed") except CONNECTION_ERRORS as e: self._log.debug("connection error: {}", e) raise beets.ui.UserError("Discogs token request failed") # Save the token for later use. self._log.debug("Discogs token {}, secret {}", token, secret) with open(self._tokenfile(), "w") as f: json.dump({"token": token, "secret": secret}, f) return token, secret def get_track_from_album( self, album_info: AlbumInfo, compare: Callable[[TrackInfo], float] ) -> TrackInfo | None: """Return the best matching track of the release.""" scores_and_tracks = [(compare(t), t) for t in album_info.tracks] score, track_info = min(scores_and_tracks, key=lambda x: x[0]) if score > 0.3: return None track_info["artist"] = album_info.artist track_info["artist_id"] = album_info.artist_id track_info["album"] = album_info.album return track_info def item_candidates( self, item: Item, artist: str, title: str ) -> Iterator[TrackInfo]: albums = self.candidates([item], artist, title, False) def compare_func(track_info: TrackInfo) -> float: return string_dist(track_info.title, title) tracks = (self.get_track_from_album(a, compare_func) for a in albums) return filter(None, tracks) def album_for_id(self, album_id: str) -> AlbumInfo | None: """Fetches an album by its Discogs ID and returns an AlbumInfo object or None if the album is not found. """ discogs_id = self._extract_id(album_id) if not discogs_id: return None result = Release(self.discogs_client, {"id": discogs_id}) # Try to obtain title to verify that we indeed have a valid Release try: getattr(result, "title") except DiscogsAPIError as e: if e.status_code != 404: self._log.debug( "API Error: {} (query: {})", e, result.data["resource_url"], ) if e.status_code == 401: self.reset_auth() return self.album_for_id(album_id) return None except CONNECTION_ERRORS: self._log.debug("Connection error in album lookup", exc_info=True) return None return self.get_album_info(result) def track_for_id(self, track_id: str) -> TrackInfo | None: if album := self.album_for_id(track_id): for track in album.tracks: if track.track_id == track_id: return track return None def get_search_query_with_filters( self, query_type: QueryType, items: Sequence[Item], artist: str, name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: """Build a Discogs release query and fixed release-type filter. The query is normalized to improve hit rates for punctuation-heavy album names and medium suffixes that can reduce recall. """ query = f"{artist} {name}" if va_likely else name # Strip non-word characters from query. Things like "!" and "-" can # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. query = re.sub(r"(?u)\W+", " ", query) # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(r"(?i)\b(CD|disc|vinyl)\s*\d+", "", query) filters: dict[str, str] = {"type": "release"} if not items: return query, filters for tag, api_field in self.extra_discogs_field_by_tag.items(): most_common, _count = util.plurality( item.get(tag) for item in items ) if most_common is None: continue value = str(most_common) if tag == "catalognum": value = value.replace(" ", "") filters[api_field] = value return query, filters def get_search_response(self, params: SearchParams) -> Sequence[IDResponse]: """Search Discogs releases and return raw result mappings with IDs.""" results = self.discogs_client.search(params.query, **params.filters) results.per_page = params.limit return [r.data for r in results.page(1)] @cache def get_master_year(self, master_id: str) -> int | None: """Fetches a master release given its Discogs ID and returns its year or None if the master release is not found. """ self._log.debug("Getting master release {}", master_id) result = Master(self.discogs_client, {"id": master_id}) try: return result.fetch("year") except DiscogsAPIError as e: if e.status_code != 404: self._log.debug( "API Error: {} (query: {})", e, result.data["resource_url"], ) if e.status_code == 401: self.reset_auth() return self.get_master_year(master_id) return None except CONNECTION_ERRORS: self._log.debug( "Connection error in master release lookup", exc_info=True ) return None @staticmethod def get_media_and_albumtype( formats: list[ReleaseFormat] | None, ) -> tuple[str | None, str | None]: media = albumtype = None if formats and (first_format := formats[0]): if descriptions := first_format["descriptions"]: albumtype = ", ".join(descriptions) media = first_format["name"] return media, albumtype def get_album_info(self, result: Release) -> AlbumInfo | None: """Returns an AlbumInfo object for a discogs Release object.""" # Explicitly reload the `Release` fields, as they might not be yet # present if the result is from a `discogs_client.search()`. if not result.data.get("artists"): try: result.refresh() except CONNECTION_ERRORS: self._log.debug( "Connection error in release lookup: {0}", result, ) return None # Sanity check for required fields. The list of required fields is # defined at Guideline 1.3.1.a, but in practice some releases might be # lacking some of these fields. This function expects at least: # `artists` (>0), `title`, `id`, `tracklist` (>0) # https://www.discogs.com/help/doc/submission-guidelines-general-rules if not all( [ result.data.get(k) for k in ["artists", "title", "id", "tracklist"] ] ): self._log.warning("Release does not contain the required fields") return None artist_data = [a.data for a in result.artists] # Information for the album artist albumartist = ArtistState.from_config( self.config, artist_data, for_album_artist=True ) album = re.sub(r" +", " ", result.title) album_id = result.data["id"] # Use `.data` to access the tracklist directly instead of the # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks( result.data["tracklist"], ArtistState.from_config(self.config, artist_data), ) # Extract information for the optional AlbumInfo fields, if possible. va = albumartist.artist == config["va_name"].as_str() year = result.data.get("year") mediums = [t["medium"] for t in tracks] country = result.data.get("country") data_url = result.data.get("uri") styles: list[str] = result.data.get("styles") or [] genres: list[str] = result.data.get("genres") or [] if self.config["append_style_genre"]: genres.extend(styles) discogs_albumid = self._extract_id(result.data.get("uri")) # Extract information for the optional AlbumInfo fields that are # contained on nested discogs fields. media, albumtype = self.get_media_and_albumtype( result.data.get("formats") ) label = catalogno = labelid = None if result.data.get("labels"): label = self.strip_disambiguation( result.data["labels"][0].get("name") ) catalogno = result.data["labels"][0].get("catno") labelid = result.data["labels"][0].get("id") cover_art_url = self.select_cover_art(result) # Additional cleanups # (catalog number, media, disambiguation). if catalogno == "none": catalogno = None # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = f"{album_id}-{track.track_alt}" track.data_url = data_url track.data_source = "Discogs" # Retrieve master release id (returns None if there isn't one). master_id = result.data.get("master_id") # Assume `original_year` is equal to `year` for releases without # a master release, otherwise fetch the master release. original_year = self.get_master_year(master_id) if master_id else year return AlbumInfo( album=album, album_id=album_id, **albumartist.info, # Unpacks values to satisfy the keyword arguments tracks=tracks, albumtype=albumtype, va=va, year=year, label=label, mediums=len(set(mediums)), releasegroup_id=master_id, catalognum=catalogno, country=country, style=( self.config["separator"].as_str().join(sorted(styles)) or None ), genres=sorted(genres), media=media, original_year=original_year, data_source=self.data_source, data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, discogs_artistid=albumartist.artist_id, cover_art_url=cover_art_url, ) def select_cover_art(self, result: Release) -> str | None: """Returns the best candidate image, if any, from a Discogs `Release` object.""" if result.data.get("images") and len(result.data.get("images")) > 0: # The first image in this list appears to be the one displayed first # on the release page - even if it is not flagged as `type: "primary"` - and # so it is the best candidate for the cover art. return result.data.get("images")[0].get("uri") return None def get_tracks( self, tracklist: list[Track], albumartistinfo: ArtistState, ) -> list[TrackInfo]: """Returns a list of TrackInfo objects for a discogs tracklist.""" try: clean_tracklist: list[Track] = self._coalesce_tracks(tracklist) except Exception as exc: # FIXME: this is an extra precaution for making sure there are no # side effects after #2222. It should be removed after further # testing. self._log.debug("{}", traceback.format_exc()) self._log.error("uncaught exception in _coalesce_tracks: {}", exc) clean_tracklist = tracklist t = TracklistState.build(self, clean_tracklist, albumartistinfo) # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None medium_count, index_count, side_count = 0, 0, 0 sides_per_medium = 1 # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. if all([medium is not None for medium in t.mediums]): m = sorted( {medium.lower() if medium else "" for medium in t.mediums} ) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if "".join(m) in ascii_lowercase: sides_per_medium = 2 for i, track in enumerate(t.tracks): # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. medium_str = t.mediums[i] medium_index = t.medium_indices[i] medium_is_index = ( medium_str and not medium_index and ( len(medium_str) != 1 or # Not within standard incremental medium values (A, B, C, ...). ord(medium_str) - 64 != side_count + 1 ) ) if not medium_is_index and medium != medium_str: side_count += 1 if sides_per_medium == 2: if side_count % sides_per_medium: # Two-sided medium changed. Reset index_count. index_count = 0 medium_count += 1 else: # Medium changed. Reset index_count. medium_count += 1 index_count = 0 medium = medium_str index_count += 1 medium_count = 1 if medium_count == 0 else medium_count track.medium, track.medium_index = medium_count, index_count # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. for track in t.tracks: if track.medium_index == 1: if track.index in t.index_tracks: disctitle = t.index_tracks[track.index] else: disctitle = None track.disctitle = disctitle return t.tracks def _coalesce_tracks(self, raw_tracklist: list[Track]) -> list[Track]: """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ # Pre-process the tracklist, trying to identify subtracks. subtracks: list[Track] = [] tracklist: list[Track] = [] prev_subindex = "" for track in raw_tracklist: # Regular subtrack (track with subindex). if track["position"]: _, _, subindex = self.get_track_index(track["position"]) if subindex: if subindex.rjust(len(raw_tracklist)) > prev_subindex: # Subtrack still part of the current main track. subtracks.append(track) else: # Subtrack part of a new group (..., 1.3, *2.1*, ...). self._add_merged_subtracks(tracklist, subtracks) subtracks = [track] prev_subindex = subindex.rjust(len(raw_tracklist)) continue # Index track with nested sub_tracks. if not track["position"] and "sub_tracks" in track: # Append the index track, assuming it contains the track title. tracklist.append(track) self._add_merged_subtracks(tracklist, track["sub_tracks"]) continue # Regular track or index track without nested sub_tracks. if subtracks: self._add_merged_subtracks(tracklist, subtracks) subtracks = [] prev_subindex = "" tracklist.append(track) # Merge and add the remaining subtracks, if any. if subtracks: self._add_merged_subtracks(tracklist, subtracks) return tracklist def _add_merged_subtracks( self, tracklist: list[Track], subtracks: list[Track], ) -> None: """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. idx, medium_idx, sub_idx = self.get_track_index( subtracks[0]["position"] ) position = f"{idx or ''}{medium_idx or ''}" if tracklist and not tracklist[-1]["position"]: # Assume the previous index track contains the track title. if sub_idx: # "Convert" the track title to a real track, discarding the # subtracks assuming they are logical divisions of a # physical track (12.2.9 Subtracks). tracklist[-1]["position"] = position else: # Promote the subtracks to real tracks, discarding the # index track, assuming the subtracks are physical tracks. index_track = tracklist.pop() # Fix artists when they are specified on the index track. if index_track.get("artists"): for subtrack in subtracks: if not subtrack.get("artists"): subtrack["artists"] = index_track["artists"] # Concatenate index with track title when index_tracks # option is set if self.config["index_tracks"]: for subtrack in subtracks: subtrack["title"] = ( f"{index_track['title']}: {subtrack['title']}" ) tracklist.extend(subtracks) else: # Merge the subtracks, pick a title, and append the new track. track = subtracks[0].copy() track["title"] = " / ".join([t["title"] for t in subtracks]) tracklist.append(track) def strip_disambiguation(self, text: str) -> str: """Removes discogs specific disambiguations from a string. Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)' to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False.""" if not self.config["strip_disambiguation"]: return text return DISAMBIGUATION_RE.sub("", text) def get_track_info( self, track: Track, index: int, divisions: list[str], albumartistinfo: ArtistState, ) -> tuple[TrackInfo, str | None, str | None]: """Returns a TrackInfo object for a discogs track.""" title = track["title"] if self.config["index_tracks"]: prefix = ", ".join(divisions) if prefix: title = f"{prefix}: {title}" track_id = None medium, medium_index, _ = self.get_track_index(track["position"]) length = self.get_track_length(track["duration"]) # If artists are found on the track, we will use those instead artistinfo = ArtistState.from_config( self.config, [ *(track.get("artists") or albumartistinfo.raw_artists), *track.get("extraartists", []), ], ) return ( TrackInfo( title=title, track_id=track_id, **artistinfo.info, length=length, index=index, ), medium, medium_index, ) @staticmethod def get_track_index( position: str, ) -> tuple[str | None, str | None, str | None]: """Returns the medium, medium index and subtrack index for a discogs track position.""" # Match the standard Discogs positions (12.2.9), which can have several # forms (1, 1-1, A1, A1.1, A1a, ...). medium = index = subindex = None if match := TRACK_INDEX_RE.fullmatch(position.upper()): medium, index, subindex = match.groups() if subindex and subindex.startswith("."): subindex = subindex[1:] return medium or None, index or None, subindex or None def get_track_length(self, duration: str) -> int | None: """Returns the track length in seconds for a discogs duration.""" try: length = time.strptime(duration, "%M:%S") except ValueError: return None return length.tm_min * 60 + length.tm_sec ================================================ FILE: beetsplug/discogs/states.py ================================================ # This file is part of beets. # Copyright 2025, Sarunas Nejus, Henry Oberholtzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Dataclasses for managing artist credits and tracklists from Discogs.""" from __future__ import annotations import re from dataclasses import asdict, dataclass, field from functools import cached_property from typing import TYPE_CHECKING, NamedTuple from beets import config from .types import ArtistInfo if TYPE_CHECKING: from confuse import ConfigView from beets.autotag.hooks import TrackInfo from . import DiscogsPlugin from .types import Artist, Track, TracklistInfo DISAMBIGUATION_RE = re.compile(r" \(\d+\)") @dataclass class ArtistState: """Represent Discogs artist credits. This object centralizes the plugin's policy for which Discogs artist fields to prefer (name vs. ANV), how to treat 'Various', how to format join phrases, and how to separate featured artists. It exposes both per-artist components and fully joined strings for common tag targets like 'artist' and 'artist_credit'. """ class ValidArtist(NamedTuple): """A normalized, render-ready artist entry extracted from Discogs data. Instances represent the subset of Discogs artist information needed for tagging, including the join token following the artist and whether the entry is considered a featured appearance. """ id: str name: str credit: str join: str is_feat: bool def get_artist(self, property_name: str) -> str: """Return the requested display field with its trailing join token. The join token is normalized so commas become ', ' and other join phrases are surrounded with spaces, producing a single fragment that can be concatenated to form a full artist string. """ join = {",": ", ", "": ""}.get(self.join, f" {self.join} ") return f"{getattr(self, property_name)}{join}" raw_artists: list[Artist] use_anv: bool use_credit_anv: bool featured_string: str should_strip_disambiguation: bool @property def info(self) -> ArtistInfo: """Expose the state in the shape expected by downstream tag mapping.""" return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value] def strip_disambiguation(self, text: str) -> str: """Strip Discogs disambiguation suffixes from an artist or label string. This removes Discogs-specific numeric suffixes like 'Name (5)' and can be applied to multi-artist strings as well (e.g., 'A (1) & B (2)'). When the feature is disabled, the input is returned unchanged. """ if self.should_strip_disambiguation: return DISAMBIGUATION_RE.sub("", text) return text @cached_property def valid_artists(self) -> list[ValidArtist]: """Build the ordered, filtered list of artists used for rendering. The resulting list normalizes Discogs entries by: - substituting the configured 'Various Artists' name when Discogs uses 'Various' - choosing between name and ANV according to plugin settings - excluding non-empty roles unless they indicate a featured appearance - capturing join tokens so the original credit formatting is preserved """ va_name = config["va_name"].as_str() return [ self.ValidArtist( str(a["id"]), self.strip_disambiguation(anv if self.use_anv else name), self.strip_disambiguation(anv if self.use_credit_anv else name), a["join"].strip(), is_feat, ) for a in self.raw_artists if ( (name := va_name if a["name"] == "Various" else a["name"]) and (anv := a["anv"] or name) and ( (is_feat := ("featuring" in a["role"].lower())) or not a["role"] ) ) ] @property def artists_ids(self) -> list[str]: """Return Discogs artist IDs for all valid artists, preserving order.""" return [a.id for a in self.valid_artists] @property def artist_id(self) -> str: """Return the primary Discogs artist ID.""" return self.artists_ids[0] @property def artists(self) -> list[str]: """Return the per-artist display names used for the 'artist' field.""" return [a.name for a in self.valid_artists] @property def artists_credit(self) -> list[str]: """Return the per-artist display names used for the credit field.""" return [a.credit for a in self.valid_artists] @property def artist(self) -> str: """Return the fully rendered artist string using display names.""" return self.join_artists("name") @property def artist_credit(self) -> str: """Return the fully rendered artist credit string.""" return self.join_artists("credit") def join_artists(self, property_name: str) -> str: """Render a single artist string with join phrases and featured artists. Non-featured artists are concatenated using their join tokens. Featured artists are appended after the configured 'featured' marker, preserving Discogs order while keeping featured credits separate from the main artist string. """ non_featured = [a for a in self.valid_artists if not a.is_feat] featured = [a for a in self.valid_artists if a.is_feat] artist = "".join(a.get_artist(property_name) for a in non_featured) if featured: if "feat" not in artist: artist += f" {self.featured_string} " artist += ", ".join(a.get_artist(property_name) for a in featured) return artist @classmethod def from_config( cls, config: ConfigView, artists: list[Artist], for_album_artist: bool = False, ) -> ArtistState: return cls( artists, config["anv"]["album_artist" if for_album_artist else "artist"].get( bool ), config["anv"]["artist_credit"].get(bool), config["featured_string"].as_str(), config["strip_disambiguation"].get(bool), ) @dataclass class TracklistState: index: int = 0 index_tracks: dict[int, str] = field(default_factory=dict) tracks: list[TrackInfo] = field(default_factory=list) divisions: list[str] = field(default_factory=list) next_divisions: list[str] = field(default_factory=list) mediums: list[str | None] = field(default_factory=list) medium_indices: list[str | None] = field(default_factory=list) @property def info(self) -> TracklistInfo: return asdict(self) # type: ignore[return-value] @classmethod def build( cls, plugin: DiscogsPlugin, clean_tracklist: list[Track], albumartistinfo: ArtistState, ) -> TracklistState: state = cls() for track in clean_tracklist: if track["position"]: state.index += 1 if state.next_divisions: state.divisions += state.next_divisions state.next_divisions.clear() track_info, medium, medium_index = plugin.get_track_info( track, state.index, state.divisions, albumartistinfo ) track_info.track_alt = track["position"] state.tracks.append(track_info) state.mediums.append(medium or None) state.medium_indices.append(medium_index or None) else: state.next_divisions.append(track["title"]) try: state.divisions.pop() except IndexError: pass state.index_tracks[state.index + 1] = track["title"] return state ================================================ FILE: beetsplug/discogs/types.py ================================================ # This file is part of beets. # Copyright 2025, Sarunas Nejus, Henry Oberholtzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations from typing import TYPE_CHECKING from typing_extensions import NotRequired, TypedDict if TYPE_CHECKING: from beets.autotag.hooks import TrackInfo class ReleaseFormat(TypedDict): name: str qty: int descriptions: list[str] | None class Artist(TypedDict): name: str anv: str join: str role: str tracks: str id: str resource_url: str class Track(TypedDict): position: str type_: str title: str duration: str artists: list[Artist] extraartists: NotRequired[list[Artist]] sub_tracks: NotRequired[list[Track]] class ArtistInfo(TypedDict): artist: str artists: list[str] artist_credit: str artists_credit: list[str] artist_id: str artists_ids: list[str] class TracklistInfo(TypedDict): index: int index_tracks: dict[int, str] tracks: list[TrackInfo] divisions: list[str] next_divisions: list[str] mediums: list[str | None] medium_indices: list[str | None] ================================================ FILE: beetsplug/duplicates.py ================================================ # This file is part of beets. # Copyright 2016, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """List duplicate tracks or albums.""" import os import shlex from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, UserError, print_ from beets.util import ( MoveOperation, bytestring_path, command_output, displayable_path, subprocess, ) PLUGIN = "duplicates" class DuplicatesPlugin(BeetsPlugin): """List duplicate tracks or albums""" def __init__(self): super().__init__() self.config.add( { "album": False, "checksum": "", "copy": "", "count": False, "delete": False, "format": "", "full": False, "keys": [], "merge": False, "move": "", "path": False, "tiebreak": {}, "strict": False, "tag": "", "remove": False, } ) self._command = Subcommand("duplicates", help=__doc__, aliases=["dup"]) self._command.parser.add_option( "-c", "--count", dest="count", action="store_true", help="show duplicate counts", ) self._command.parser.add_option( "-C", "--checksum", dest="checksum", action="store", metavar="PROG", help="report duplicates based on arbitrary command", ) self._command.parser.add_option( "-d", "--delete", dest="delete", action="store_true", help="delete items from library and disk", ) self._command.parser.add_option( "-F", "--full", dest="full", action="store_true", help="show all versions of duplicate tracks or albums", ) self._command.parser.add_option( "-s", "--strict", dest="strict", action="store_true", help="report duplicates only if all attributes are set", ) self._command.parser.add_option( "-k", "--key", dest="keys", action="append", metavar="KEY", help="report duplicates based on keys (use multiple times)", ) self._command.parser.add_option( "-M", "--merge", dest="merge", action="store_true", help="merge duplicate items", ) self._command.parser.add_option( "-m", "--move", dest="move", action="store", metavar="DEST", help="move items to dest", ) self._command.parser.add_option( "-o", "--copy", dest="copy", action="store", metavar="DEST", help="copy items to dest", ) self._command.parser.add_option( "-t", "--tag", dest="tag", action="store", help="tag matched items with 'k=v' attribute", ) self._command.parser.add_option( "-r", "--remove", dest="remove", action="store_true", help="remove items from library", ) self._command.parser.add_all_common_options() def commands(self): def _dup(lib, opts, args): self.config.set_args(opts) album = self.config["album"].get(bool) checksum = self.config["checksum"].get(str) copy = bytestring_path(self.config["copy"].as_str()) count = self.config["count"].get(bool) delete = self.config["delete"].get(bool) remove = self.config["remove"].get(bool) fmt_tmpl = self.config["format"].get(str) full = self.config["full"].get(bool) keys = self.config["keys"].as_str_seq() merge = self.config["merge"].get(bool) move = bytestring_path(self.config["move"].as_str()) path = self.config["path"].get(bool) tiebreak = self.config["tiebreak"].get(dict) strict = self.config["strict"].get(bool) tag = self.config["tag"].get(str) if album: if not keys: keys = ["mb_albumid"] items = lib.albums(args) else: if not keys: keys = ["mb_trackid", "mb_albumid"] items = lib.items(args) # If there's nothing to do, return early. The code below assumes # `items` to be non-empty. if not items: return if path: fmt_tmpl = "$path" # Default format string for count mode. if count and not fmt_tmpl: if album: fmt_tmpl = "$albumartist - $album" else: fmt_tmpl = "$albumartist - $album - $title" if checksum: for i in items: k, _ = self._checksum(i, checksum) keys = [k] for obj_id, obj_count, objs in self._duplicates( items, keys=keys, full=full, strict=strict, tiebreak=tiebreak, merge=merge, ): if obj_id: # Skip empty IDs. for o in objs: self._process_item( o, copy=copy, move=move, delete=delete, remove=remove, tag=tag, fmt=f"{fmt_tmpl}: {obj_count}", ) self._command.func = _dup return [self._command] def _process_item( self, item, copy=False, move=False, delete=False, tag=False, fmt="", remove=False, ): """Process Item `item`.""" print_(format(item, fmt)) if copy: item.move(basedir=copy, operation=MoveOperation.COPY) item.store() if move: item.move(basedir=move) item.store() if delete: item.remove(delete=True) elif remove: item.remove(delete=False) if tag: try: k, v = tag.split("=") except Exception: raise UserError(f"{PLUGIN}: can't parse k=v tag: {tag}") setattr(item, k, v) item.store() def _checksum(self, item, prog): """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. """ args = [ p.format(file=os.fsdecode(item.path)) for p in shlex.split(prog) ] key = args[0] checksum = getattr(item, key, False) if not checksum: self._log.debug( "key {} on item {.filepath} not cached:computing checksum", key, item, ) try: checksum = command_output(args).stdout setattr(item, key, checksum) item.store() self._log.debug( "computed checksum for {.title} using {}", item, key ) except subprocess.CalledProcessError as e: self._log.debug("failed to checksum {.filepath}: {}", item, e) else: self._log.debug( "key {} on item {.filepath} cached:not computing checksum", key, item, ) return key, checksum def _group_by(self, objs, keys, strict): """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. If strict, all attributes must be defined for a duplicate match. """ import collections counts = collections.defaultdict(list) for obj in objs: values = [getattr(obj, k, None) for k in keys] values = [v for v in values if v not in (None, "")] if strict and len(values) < len(keys): self._log.debug( "some keys {} on item {.filepath} are null or empty: skipping", keys, obj, ) elif not strict and not len(values): self._log.debug( "all keys {} on item {.filepath} are null or empty: skipping", keys, obj, ) else: key = tuple(values) counts[key].append(obj) return counts def _order(self, objs, tiebreak=None): """Return the objects (Items or Albums) sorted by descending order of priority. If provided, the `tiebreak` dict indicates the field to use to prioritize the objects. Otherwise, Items are placed in order of "completeness" (objects with more non-null fields come first) and Albums are ordered by their track count. """ kind = "items" if all(isinstance(o, Item) for o in objs) else "albums" if tiebreak and kind in tiebreak.keys(): def key(x): return tuple(getattr(x, k) for k in tiebreak[kind]) else: if kind == "items": def truthy(v): # Avoid a Unicode warning by avoiding comparison # between a bytes object and the empty Unicode # string ''. return v is not None and ( v != "" if isinstance(v, str) else True ) fields = Item.all_keys() def key(x): return sum(1 for f in fields if truthy(getattr(x, f))) else: def key(x): return len(x.items()) return sorted(objs, key=key, reverse=True) def _merge_items(self, objs): """Merge Item objs by copying missing fields from items in the tail to the head item. Return same number of items, with the head item modified. """ fields = Item.all_keys() for f in fields: for o in objs[1:]: if getattr(objs[0], f, None) in (None, ""): value = getattr(o, f, None) if value: self._log.debug( "key {} on item {} is null " "or empty: setting from item {.filepath}", f, displayable_path(objs[0].path), o, ) setattr(objs[0], f, value) objs[0].store() break return objs def _merge_albums(self, objs): """Merge Album objs by copying missing items from albums in the tail to the head album. Return same number of albums, with the head album modified.""" ids = [i.mb_trackid for i in objs[0].items()] for o in objs[1:]: for i in o.items(): if i.mb_trackid not in ids: missing = Item.from_path(i.path) missing.album_id = objs[0].id missing.add(i._db) self._log.debug( "item {} missing from album {}:" " merging from {.filepath} into {}", missing, objs[0], o, displayable_path(missing.destination()), ) missing.move(operation=MoveOperation.COPY) return objs def _merge(self, objs): """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` for the relevant strategies. """ kind = Item if all(isinstance(o, Item) for o in objs) else Album if kind is Item: objs = self._merge_items(objs) else: objs = self._merge_albums(objs) return objs def _duplicates(self, objs, keys, full, strict, tiebreak, merge): """Generate triples of keys, duplicate counts, and constituent objects.""" offset = 0 if full else 1 for k, objs in self._group_by(objs, keys, strict).items(): if len(objs) > 1: objs = self._order(objs, tiebreak) if merge: objs = self._merge(objs) yield (k, len(objs) - offset, objs[offset:]) ================================================ FILE: beetsplug/edit.py ================================================ # This file is part of beets. # Copyright 2016 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Open metadata information in a text editor to let the user edit it.""" import codecs import os import shlex import subprocess from tempfile import NamedTemporaryFile import yaml from beets import plugins, ui, util from beets.dbcore import types from beets.importer import Action from beets.ui.commands.utils import do_query from beets.util import PromptChoice # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. SAFE_TYPES = ( types.BaseFloat, types.BaseInteger, types.Boolean, types.DelimitedString, ) class ParseError(Exception): """The modified file is unreadable. The user should be offered a chance to fix the error. """ def edit(filename, log): """Open `filename` in a text editor.""" cmd = shlex.split(util.editor_command()) cmd.append(filename) log.debug("invoking editor command: {!r}", cmd) try: subprocess.call(cmd) except OSError as exc: raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}") def dump(arg): """Dump a sequence of dictionaries as YAML for editing.""" return yaml.safe_dump_all( arg, allow_unicode=True, default_flow_style=False, ) def load(s): """Read a sequence of YAML documents back to a list of dictionaries with string keys. Can raise a `ParseError`. """ try: out = [] for d in yaml.safe_load_all(s): if not isinstance(d, dict): raise ParseError( f"each entry must be a dictionary; found {type(d).__name__}" ) # Convert all keys to strings. They started out as strings, # but the user may have inadvertently messed this up. out.append({str(k): v for k, v in d.items()}) except yaml.YAMLError as e: raise ParseError(f"invalid YAML: {e}") return out def _safe_value(obj, key, value): """Check whether the `value` is safe to represent in YAML and trust as returned from parsed YAML. This ensures that values do not change their type when the user edits their YAML representation. """ typ = obj._type(key) return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) def flatten(obj, fields): """Represent `obj`, a `dbcore.Model` object, as a dictionary for serialization. Only include the given `fields` if provided; otherwise, include everything. The resulting dictionary's keys are strings and the values are safely YAML-serializable types. """ # Format each value. d = {} for key in obj.keys(): value = obj[key] if _safe_value(obj, key, value): # A safe value that is faithfully representable in YAML. d[key] = value else: # A value that should be edited as a string. d[key] = obj.formatted()[key] # Possibly filter field names. if fields: return {k: v for k, v in d.items() if k in fields} else: return d def apply_(obj, data): """Set the fields of a `dbcore.Model` object according to a dictionary. This is the opposite of `flatten`. The `data` dictionary should have strings as values. """ for key, value in data.items(): if _safe_value(obj, key, value): # A safe value *stayed* represented as a safe type. Assign it # directly. obj[key] = value else: # Either the field was stringified originally or the user changed # it from a safe type to an unsafe one. Parse it as a string. obj.set_parse(key, str(value)) class EditPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add( { # The default fields to edit. "albumfields": "album albumartist", "itemfields": "track title artist album", # Silently ignore any changes to these fields. "ignore_fields": "id path", } ) self.register_listener( "before_choose_candidate", self.before_choose_candidate_listener ) def commands(self): edit_command = ui.Subcommand("edit", help="interactively edit metadata") edit_command.parser.add_option( "-f", "--field", metavar="FIELD", action="append", help="edit this field also", ) edit_command.parser.add_option( "--all", action="store_true", dest="all", help="edit all fields", ) edit_command.parser.add_album_option() edit_command.func = self._edit_command return [edit_command] def _edit_command(self, lib, opts, args): """The CLI command function for the `beet edit` command.""" # Get the objects to edit. items, albums = do_query(lib, args, opts.album, False) objs = albums if opts.album else items if not objs: ui.print_("Nothing to edit.") return # Get the fields to edit. if opts.all: fields = None else: fields = self._get_fields(opts.album, opts.field) self.edit(opts.album, objs, fields) def _get_fields(self, album, extra): """Get the set of fields to edit.""" # Start with the configured base fields. if album: fields = self.config["albumfields"].as_str_seq() else: fields = self.config["itemfields"].as_str_seq() # Add the requested extra fields. if extra: fields += extra # Ensure we always have the `id` field for identification. fields.append("id") return set(fields) def edit(self, album, objs, fields): """The core editor function. - `album`: A flag indicating whether we're editing Items or Albums. - `objs`: The `Item`s or `Album`s to edit. - `fields`: The set of field names to edit (or None to edit everything). """ # Present the YAML to the user and let them change it. success = self.edit_objects(objs, fields) # Save the new data. if success: self.save_changes(objs) def edit_objects(self, objs, fields): """Dump a set of Model objects to a file as text, ask the user to edit it, and apply any changes to the objects. Return a boolean indicating whether the edit succeeded. """ # Get the content to edit as raw data structures. old_data = [flatten(o, fields) for o in objs] # Set up a temporary file with the initial data for editing. new = NamedTemporaryFile( mode="w", suffix=".yaml", delete=False, encoding="utf-8" ) old_str = dump(old_data) new.write(old_str) new.close() # Loop until we have parseable data and the user confirms. try: while True: # Ask the user to edit the data. edit(new.name, self._log) # Read the data back after editing and check whether anything # changed. with codecs.open(new.name, encoding="utf-8") as f: new_str = f.read() if new_str == old_str: ui.print_("No changes; aborting.") return False # Parse the updated data. try: new_data = load(new_str) except ParseError as e: ui.print_(f"Could not read data: {e}") if ui.input_yn("Edit again to fix? (Y/n)", True): continue else: return False # Show the changes. # If the objects are not on the DB yet, we need a copy of their # original state for show_model_changes. objs_old = [obj.copy() if obj.id < 0 else None for obj in objs] self.apply_data(objs, old_data, new_data) changed = False for obj, obj_old in zip(objs, objs_old): changed |= ui.show_model_changes(obj, obj_old) if not changed: ui.print_("No changes to apply.") return False # For cancel/keep-editing, restore objects to their original # in-memory state so temp edits don't leak into the session choice = ui.input_options( ("continue Editing", "apply", "cancel") ) if choice == "a": # Apply. return True elif choice == "c": # Cancel. self.apply_data(objs, new_data, old_data) return False elif choice == "e": # Keep editing. self.apply_data(objs, new_data, old_data) continue # Remove the temporary file before returning. finally: os.remove(new.name) def apply_data(self, objs, old_data, new_data): """Take potentially-updated data and apply it to a set of Model objects. The objects are not written back to the database, so the changes are temporary. """ if len(old_data) != len(new_data): self._log.warning( "number of objects changed from {} to {}", len(old_data), len(new_data), ) obj_by_id = {o.id: o for o in objs} ignore_fields = self.config["ignore_fields"].as_str_seq() for old_dict, new_dict in zip(old_data, new_data): # Prohibit any changes to forbidden fields to avoid # clobbering `id` and such by mistake. forbidden = False for key in ignore_fields: if old_dict.get(key) != new_dict.get(key): self._log.warning("ignoring object whose {} changed", key) forbidden = True break if forbidden: continue id_ = int(old_dict["id"]) apply_(obj_by_id[id_], new_dict) def save_changes(self, objs): """Save a list of updated Model objects to the database.""" # Save to the database and possibly write tags. for ob in objs: if ob._dirty: self._log.debug("saving changes to {}", ob) ob.try_sync(ui.should_write(), ui.should_move()) # Methods for interactive importer execution. def before_choose_candidate_listener(self, session, task): """Append an "Edit" choice and an "edit Candidates" choice (if there are candidates) to the interactive importer prompt. """ choices = [PromptChoice("d", "eDit", self.importer_edit)] if task.candidates: choices.append( PromptChoice( "c", "edit Candidates", self.importer_edit_candidate ) ) return choices def importer_edit(self, session, task): """Callback for invoking the functionality during an interactive import session on the *original* item tags. """ # Assign negative temporary ids to Items that are not in the database # yet. By using negative values, no clash with items in the database # can occur. for i, obj in enumerate(task.items, start=1): # The importer may set the id to None when re-importing albums. if not obj._db or obj.id is None: obj.id = -i # Present the YAML to the user and let them change it. fields = self._get_fields(album=False, extra=[]) success = self.edit_objects(task.items, fields) # Remove temporary ids. for obj in task.items: if obj.id < 0: obj.id = None # Save the new data. if success: # Return Action.RETAG, which makes the importer write the tags # to the files if needed without re-applying metadata. return Action.RETAG else: return None def importer_edit_candidate(self, session, task): """Callback for invoking the functionality during an interactive import session on a *candidate*. The candidate's metadata is applied to the original items. """ # Prompt the user for a candidate. sel = ui.input_options([], numrange=(1, len(task.candidates))) # Force applying the candidate on the items. task.match = task.candidates[sel - 1] task.apply_metadata() return self.importer_edit(session, task) ================================================ FILE: beetsplug/embedart.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows beets to embed album art into file metadata.""" import os.path import tempfile from mimetypes import guess_extension import requests from beets import config, ui from beets.plugins import BeetsPlugin from beets.ui import print_ from beets.util import bytestring_path, displayable_path, normpath, syspath from beets.util.artresizer import ArtResizer from beetsplug._utils import art def _confirm(objs, album): """Show the list of affected objects (items or albums) and confirm that the user wants to modify their artwork. `album` is a Boolean indicating whether these are albums (as opposed to items). """ noun = "album" if album else "file" prompt = ( "Modify artwork for" f" {len(objs)} {noun}{'s' if len(objs) > 1 else ''} (Y/n)?" ) # Show all the items or albums. for obj in objs: print_(format(obj)) # Confirm with user. return ui.input_yn(prompt) class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files.""" def __init__(self): super().__init__() self.config.add( { "maxwidth": 0, "auto": True, "compare_threshold": 0, "ifempty": False, "remove_art_file": False, "quality": 0, "clearart_on_import": False, } ) if self.config["maxwidth"].get(int) and not ArtResizer.shared.local: self.config["maxwidth"] = 0 self._log.warning( "ImageMagick or PIL not found; 'maxwidth' option ignored" ) if ( self.config["compare_threshold"].get(int) and not ArtResizer.shared.can_compare ): self.config["compare_threshold"] = 0 self._log.warning( "ImageMagick 6.8.7 or higher not installed; " "'compare_threshold' option ignored" ) self.register_listener("art_set", self.process_album) if self.config["clearart_on_import"].get(bool): self.register_listener("import_task_files", self.import_task_files) def commands(self): # Embed command. embed_cmd = ui.Subcommand( "embedart", help="embed image files into file metadata" ) embed_cmd.parser.add_option( "-f", "--file", metavar="PATH", help="the image file to embed" ) embed_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) embed_cmd.parser.add_option( "-u", "--url", metavar="URL", help="the URL of the image file to embed", ) maxwidth = self.config["maxwidth"].get(int) quality = self.config["quality"].get(int) compare_threshold = self.config["compare_threshold"].get(int) ifempty = self.config["ifempty"].get(bool) def embed_func(lib, opts, args): if opts.file: imagepath = normpath(opts.file) if not os.path.isfile(syspath(imagepath)): raise ui.UserError( f"image file {displayable_path(imagepath)} not found" ) items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, not opts.file): return for item in items: art.embed_item( self._log, item, imagepath, maxwidth, None, compare_threshold, ifempty, quality=quality, ) elif opts.url: try: response = requests.get(opts.url, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: self._log.error("{}", e) return extension = guess_extension(response.headers["Content-Type"]) if extension is None: self._log.error("Invalid image file") return file = f"image{extension}" tempimg = os.path.join(tempfile.gettempdir(), file) try: with open(tempimg, "wb") as f: f.write(response.content) except Exception as e: self._log.error("Unable to save image: {}", e) return items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, not opts.url): os.remove(tempimg) return for item in items: art.embed_item( self._log, item, tempimg, maxwidth, None, compare_threshold, ifempty, quality=quality, ) os.remove(tempimg) else: albums = lib.albums(args) # Confirm with user. if not opts.yes and not _confirm(albums, not opts.file): return for album in albums: art.embed_album( self._log, album, maxwidth, False, compare_threshold, ifempty, quality=quality, ) self.remove_artfile(album) embed_cmd.func = embed_func # Extract command. extract_cmd = ui.Subcommand( "extractart", help="extract an image from file metadata", ) extract_cmd.parser.add_option( "-o", dest="outpath", help="image output file", ) extract_cmd.parser.add_option( "-n", dest="filename", help="image filename to create for all matched albums", ) extract_cmd.parser.add_option( "-a", dest="associate", action="store_true", help="associate the extracted images with the album", ) def extract_func(lib, opts, args): if opts.outpath: art.extract_first( self._log, normpath(opts.outpath), lib.items(args) ) else: filename = bytestring_path( opts.filename or config["art_filename"].get() ) if os.path.dirname(filename) != b"": self._log.error( "Only specify a name rather than a path for -n" ) return for album in lib.albums(args): artpath = normpath(os.path.join(album.path, filename)) artpath = art.extract_first( self._log, artpath, album.items() ) if artpath and opts.associate: album.set_art(artpath) album.store() extract_cmd.func = extract_func # Clear command. clear_cmd = ui.Subcommand( "clearart", help="remove images from file metadata", ) clear_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) def clear_func(lib, opts, args): items = lib.items(args) # Confirm with user. if not opts.yes and not _confirm(items, False): return art.clear(self._log, lib, args) clear_cmd.func = clear_func return [embed_cmd, extract_cmd, clear_cmd] def process_album(self, album): """Automatically embed art after art has been set""" if self.config["auto"] and ui.should_write(): max_width = self.config["maxwidth"].get(int) art.embed_album( self._log, album, max_width, True, self.config["compare_threshold"].get(int), self.config["ifempty"].get(bool), ) self.remove_artfile(album) def remove_artfile(self, album): """Possibly delete the album art file for an album (if the appropriate configuration option is enabled). """ if self.config["remove_art_file"] and album.artpath: if os.path.isfile(syspath(album.artpath)): self._log.debug("Removing album art file for {}", album) os.remove(syspath(album.artpath)) album.artpath = None album.store() def import_task_files(self, session, task): """Automatically clearart of imported files.""" for item in task.imported_items(): self._log.debug("clearart-on-import {.filepath}", item) art.clear_item(item, self._log) ================================================ FILE: beetsplug/embyupdate.py ================================================ """Updates the Emby Library whenever the beets library is changed. emby: host: localhost port: 8096 username: user apikey: apikey password: password """ import hashlib from urllib.parse import parse_qs, urlencode, urljoin, urlsplit, urlunsplit import requests from beets.plugins import BeetsPlugin def api_url(host, port, endpoint): """Returns a joined url. Takes host, port and endpoint and generates a valid emby API url. :param host: Hostname of the emby server :param port: Portnumber of the emby server :param endpoint: API endpoint :type host: str :type port: int :type endpoint: str :returns: Full API url :rtype: str """ # check if http or https is defined as host and create hostname hostname_list = [host] if host.startswith("http://") or host.startswith("https://"): hostname = "".join(hostname_list) else: hostname_list.insert(0, "http://") hostname = "".join(hostname_list) joined = urljoin(f"{hostname}:{port}", endpoint) scheme, netloc, path, query_string, fragment = urlsplit(joined) query_params = parse_qs(query_string) query_params["format"] = ["json"] new_query_string = urlencode(query_params, doseq=True) return urlunsplit((scheme, netloc, path, new_query_string, fragment)) def password_data(username, password): """Returns a dict with username and its encoded password. :param username: Emby username :param password: Emby password :type username: str :type password: str :returns: Dictionary with username and encoded password :rtype: dict """ return { "username": username, "password": hashlib.sha1(password.encode("utf-8")).hexdigest(), "passwordMd5": hashlib.md5(password.encode("utf-8")).hexdigest(), } def create_headers(user_id, token=None): """Return header dict that is needed to talk to the Emby API. :param user_id: Emby user ID :param token: Authentication token for Emby :type user_id: str :type token: str :returns: Headers for requests :rtype: dict """ headers = {} authorization = ( f'MediaBrowser UserId="{user_id}", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) headers["x-emby-authorization"] = authorization if token: headers["x-mediabrowser-token"] = token return headers def get_token(host, port, headers, auth_data): """Return token for a user. :param host: Emby host :param port: Emby port :param headers: Headers for requests :param auth_data: Username and encoded password for authentication :type host: str :type port: int :type headers: dict :type auth_data: dict :returns: Access Token :rtype: str """ url = api_url(host, port, "/Users/AuthenticateByName") r = requests.post( url, headers=headers, data=auth_data, timeout=10, ) return r.json().get("AccessToken") def get_user(host, port, username): """Return user dict from server or None if there is no user. :param host: Emby host :param port: Emby port :username: Username :type host: str :type port: int :type username: str :returns: Matched Users :rtype: list """ url = api_url(host, port, "/Users/Public") r = requests.get(url, timeout=10) user = [i for i in r.json() if i["Name"] == username] return user class EmbyUpdate(BeetsPlugin): def __init__(self): super().__init__("emby") # Adding defaults. self.config.add( { "host": "http://localhost", "port": 8096, "username": None, "password": None, "userid": None, "apikey": None, } ) self.config["username"].redact = True self.config["password"].redact = True self.config["userid"].redact = True self.config["apikey"].redact = True self.register_listener("database_change", self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update for the end.""" self.register_listener("cli_exit", self.update) def update(self, lib): """When the client exists try to send refresh request to Emby.""" self._log.info("Updating Emby library...") host = self.config["host"].get() port = self.config["port"].get() username = self.config["username"].get() password = self.config["password"].get() userid = self.config["userid"].get() token = self.config["apikey"].get() # Check if at least a apikey or password is given. if not any([password, token]): self._log.warning("Provide at least Emby password or apikey.") return if not userid: # Get user information from the Emby API. user = get_user(host, port, username) if not user: self._log.warning("User {} could not be found.", username) return userid = user[0]["Id"] if not token: # Create Authentication data and headers. auth_data = password_data(username, password) headers = create_headers(userid) # Get authentication token. token = get_token(host, port, headers, auth_data) if not token: self._log.warning("Could not get token for user {}", username) return # Recreate headers with a token. headers = create_headers(userid, token=token) # Trigger the Update. url = api_url(host, port, "/Library/Refresh") r = requests.post( url, headers=headers, timeout=10, ) if r.status_code != 204: self._log.warning("Update could not be triggered") else: self._log.info("Update triggered.") ================================================ FILE: beetsplug/export.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Exports data from beets""" import codecs import csv import json import sys from datetime import date, datetime from xml.etree import ElementTree import mediafile from beets import ui, util from beets.plugins import BeetsPlugin from beetsplug.info import library_data, tag_data class ExportEncoder(json.JSONEncoder): """Deals with dates because JSON doesn't have a standard""" def default(self, o): if isinstance(o, (datetime, date)): return o.isoformat() return json.JSONEncoder.default(self, o) class ExportPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "default_format": "json", "json": { # JSON module formatting options. "formatting": { "ensure_ascii": False, "indent": 4, "separators": (",", ": "), "sort_keys": True, } }, "jsonlines": { # JSON Lines formatting options. "formatting": { "ensure_ascii": False, "separators": (",", ": "), "sort_keys": True, } }, "csv": { # CSV module formatting options. "formatting": { # The delimiter used to separate columns. "delimiter": ",", # The dialect to use when formatting the file output. "dialect": "excel", } }, "xml": { # XML module formatting options. "formatting": {} }, # TODO: Use something like the edit plugin # 'item_fields': [] } ) def commands(self): cmd = ui.Subcommand("export", help="export data from beets") cmd.func = self.run cmd.parser.add_option( "-l", "--library", action="store_true", help="show library fields instead of tags", ) cmd.parser.add_option( "-a", "--album", action="store_true", help='show album fields instead of tracks (implies "--library")', ) cmd.parser.add_option( "--append", action="store_true", default=False, help="if should append data to the file", ) cmd.parser.add_option( "-i", "--include-keys", default=[], action="append", dest="included_keys", help="comma separated list of keys to show", ) cmd.parser.add_option( "-o", "--output", help="path for the output file. If not given, will print the data", ) cmd.parser.add_option( "-f", "--format", default="json", help="the output format: json (default), jsonlines, csv, or xml", ) return [cmd] def run(self, lib, opts, args): file_path = opts.output file_mode = "a" if opts.append else "w" file_format = opts.format or self.config["default_format"].get(str) file_format_is_line_based = file_format == "jsonlines" format_options = self.config[file_format]["formatting"].get(dict) export_format = ExportFormat.factory( file_type=file_format, **{"file_path": file_path, "file_mode": file_mode}, ) if opts.library or opts.album: data_collector = library_data else: data_collector = tag_data included_keys = [] for keys in opts.included_keys: included_keys.extend(keys.split(",")) items = [] for data_emitter in data_collector( lib, args, album=opts.album, ): try: data, _ = data_emitter(included_keys or "*") except (mediafile.UnreadableFileError, OSError) as ex: self._log.error("cannot read file: {}", ex) continue for key, value in data.items(): if isinstance(value, bytes): data[key] = util.displayable_path(value) if file_format_is_line_based: export_format.export(data, **format_options) else: items += [data] if not file_format_is_line_based: export_format.export(items, **format_options) class ExportFormat: """The output format type""" def __init__(self, file_path, file_mode="w", encoding="utf-8"): self.path = file_path self.mode = file_mode self.encoding = encoding # creates a file object to write/append or sets to stdout self.out_stream = ( codecs.open(self.path, self.mode, self.encoding) if self.path else sys.stdout ) @classmethod def factory(cls, file_type, **kwargs): if file_type in ["json", "jsonlines"]: return JsonFormat(**kwargs) elif file_type == "csv": return CSVFormat(**kwargs) elif file_type == "xml": return XMLFormat(**kwargs) else: raise NotImplementedError() def export(self, data, **kwargs): raise NotImplementedError() class JsonFormat(ExportFormat): """Saves in a json file""" def __init__(self, file_path, file_mode="w", encoding="utf-8"): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs) self.out_stream.write("\n") class CSVFormat(ExportFormat): """Saves in a csv file""" def __init__(self, file_path, file_mode="w", encoding="utf-8"): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): header = list(data[0].keys()) if data else [] writer = csv.DictWriter(self.out_stream, fieldnames=header, **kwargs) writer.writeheader() writer.writerows(data) class XMLFormat(ExportFormat): """Saves in a xml file""" def __init__(self, file_path, file_mode="w", encoding="utf-8"): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): # Creates the XML file structure. library = ElementTree.Element("library") tracks = ElementTree.SubElement(library, "tracks") if data and isinstance(data[0], dict): for index, item in enumerate(data): track = ElementTree.SubElement(tracks, "track") for key, value in item.items(): track_details = ElementTree.SubElement(track, key) track_details.text = value # Depending on the version of python the encoding needs to change try: data = ElementTree.tostring(library, encoding="unicode", **kwargs) except LookupError: data = ElementTree.tostring(library, encoding="utf-8", **kwargs) self.out_stream.write(data) ================================================ FILE: beetsplug/fetchart.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetches album art.""" from __future__ import annotations import os import re from abc import ABC, abstractmethod from collections import OrderedDict from contextlib import closing from enum import Enum from functools import cached_property from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal import confuse import requests from mediafile import image_mime_type from beets import config, importer, plugins, ui, util from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath from beets.util.artresizer import ArtResizer from beets.util.color import colorize from beets.util.config import sanitize_pairs if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence from beets.importer import ImportSession, ImportTask from beets.library import Album, Library from beets.logging import BeetsLogger as Logger try: from bs4 import BeautifulSoup, Tag HAS_BEAUTIFUL_SOUP = True except ImportError: HAS_BEAUTIFUL_SOUP = False CONTENT_TYPES = {"image/jpeg": [b"jpg", b"jpeg"], "image/png": [b"png"]} IMAGE_EXTENSIONS = [ext for exts in CONTENT_TYPES.values() for ext in exts] class ImageAction(Enum): """Indicates whether an image is useable or requires post-processing.""" BAD = 0 EXACT = 1 DOWNSCALE = 2 DOWNSIZE = 3 DEINTERLACE = 4 REFORMAT = 5 class MetadataMatch(Enum): """Indicates whether a `Candidate` matches the search criteria exactly.""" EXACT = 0 FALLBACK = 1 SourceLocation = Literal["local", "remote"] class Candidate: """Holds information about a matching artwork, deals with validation of dimension restrictions and resizing. """ def __init__( self, log: Logger, source_name: str, path: None | bytes = None, url: None | str = None, match: None | MetadataMatch = None, size: None | tuple[int, int] = None, ): self._log = log self.path = path self.url = url self.source_name = source_name self._check: None | ImageAction = None self.match = match self.size = size def _validate( self, plugin: FetchArtPlugin, skip_check_for: None | list[ImageAction] = None, ) -> ImageAction: """Determine whether the candidate artwork is valid based on its dimensions (width and ratio). `skip_check_for` is a check or list of checks to skip. This is used to avoid redundant checks when the candidate has already been validated for a particular operation without changing plugin configuration. Return `ImageAction.BAD` if the file is unusable. Return `ImageAction.EXACT` if the file is usable as-is. Return `ImageAction.DOWNSCALE` if the file must be rescaled. Return `ImageAction.DOWNSIZE` if the file must be resized, and possibly also rescaled. Return `ImageAction.DEINTERLACE` if the file must be deinterlaced. Return `ImageAction.REFORMAT` if the file has to be converted. """ if not self.path: return ImageAction.BAD if not ( plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth or plugin.max_filesize or plugin.deinterlace or plugin.cover_format ): return ImageAction.EXACT # get_size returns None if no local imaging backend is available if not self.size: self.size = ArtResizer.shared.get_size(self.path) self._log.debug("image size: {.size}", self) if not self.size: self._log.warning( "Could not get size of image (please see " "documentation for dependencies). " "The configuration options `minwidth`, " "`enforce_ratio` and `max_filesize` " "may be violated." ) return ImageAction.EXACT short_edge = min(self.size) long_edge = max(self.size) # Check minimum dimension. if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug( "image too small ({} < {.minwidth})", self.size[0], plugin ) return ImageAction.BAD # Check aspect ratio. edge_diff = long_edge - short_edge if plugin.enforce_ratio: if plugin.margin_px: if edge_diff > plugin.margin_px: self._log.debug( "image is not close enough to being " "square, ({} - {} > {.margin_px})", long_edge, short_edge, plugin, ) return ImageAction.BAD elif plugin.margin_percent: margin_px = plugin.margin_percent * long_edge if edge_diff > margin_px: self._log.debug( "image is not close enough to being " "square, ({} - {} > {})", long_edge, short_edge, margin_px, ) return ImageAction.BAD elif edge_diff: # also reached for margin_px == 0 and margin_percent == 0.0 self._log.debug( "image is not square ({} != {})", self.size[0], self.size[1] ) return ImageAction.BAD # Check maximum dimension. downscale = False if plugin.maxwidth and self.size[0] > plugin.maxwidth: self._log.debug( "image needs rescaling ({} > {.maxwidth})", self.size[0], plugin ) downscale = True # Check filesize. downsize = False if plugin.max_filesize: filesize = os.stat(syspath(self.path)).st_size if filesize > plugin.max_filesize: self._log.debug( "image needs resizing ({}B > {.max_filesize}B)", filesize, plugin, ) downsize = True # Check image format reformat = False if plugin.cover_format: fmt = ArtResizer.shared.get_format(self.path) reformat = fmt != plugin.cover_format if reformat: self._log.debug( "image needs reformatting: {} -> {.cover_format}", fmt, plugin, ) skip_check_for = skip_check_for or [] if downscale and (ImageAction.DOWNSCALE not in skip_check_for): return ImageAction.DOWNSCALE if reformat and (ImageAction.REFORMAT not in skip_check_for): return ImageAction.REFORMAT if plugin.deinterlace and ( ImageAction.DEINTERLACE not in skip_check_for ): return ImageAction.DEINTERLACE if downsize and (ImageAction.DOWNSIZE not in skip_check_for): return ImageAction.DOWNSIZE return ImageAction.EXACT def validate( self, plugin: FetchArtPlugin, skip_check_for: None | list[ImageAction] = None, ) -> ImageAction: self._check = self._validate(plugin, skip_check_for) return self._check def resize(self, plugin: FetchArtPlugin) -> None: """Resize the candidate artwork according to the plugin's configuration until it is valid or no further resizing is possible. """ # validate the candidate in case it hasn't been done yet current_check = self.validate(plugin) checks_performed = [] # we don't want to resize the image if it's valid or bad while current_check not in [ImageAction.BAD, ImageAction.EXACT]: self._resize(plugin, current_check) checks_performed.append(current_check) current_check = self.validate( plugin, skip_check_for=checks_performed ) def _resize( self, plugin: FetchArtPlugin, check: None | ImageAction = None ) -> None: """Resize the candidate artwork according to the plugin's configuration and the specified check. """ # This must only be called when _validate returned something other than # ImageAction.Bad or ImageAction.EXACT; then path and size are known. assert self.path is not None assert self.size is not None if check == ImageAction.DOWNSCALE: self.path = ArtResizer.shared.resize( plugin.maxwidth, self.path, quality=plugin.quality, max_filesize=plugin.max_filesize, ) elif check == ImageAction.DOWNSIZE: # dimensions are correct, so maxwidth is set to maximum dimension self.path = ArtResizer.shared.resize( max(self.size), self.path, quality=plugin.quality, max_filesize=plugin.max_filesize, ) elif check == ImageAction.DEINTERLACE: self.path = ArtResizer.shared.deinterlace(self.path) elif check == ImageAction.REFORMAT: self.path = ArtResizer.shared.reformat( self.path, # TODO: fix this gnarly logic to remove the need for type ignore plugin.cover_format, # type: ignore[arg-type] deinterlaced=plugin.deinterlace, ) def _logged_get(log: Logger, *args, **kwargs) -> requests.Response: """Like `requests.get`, but logs the effective URL to the specified `log` at the `DEBUG` level. Use the optional `message` parameter to specify what to log before the URL. By default, the string is "getting URL". Also sets the User-Agent header to indicate beets. """ # Use some arguments with the `send` call but most with the # `Request` construction. This is a cheap, magic-filled way to # emulate `requests.get` or, more pertinently, # `requests.Session.request`. req_kwargs = kwargs send_kwargs = {} for arg in ("stream", "verify", "proxies", "cert", "timeout"): if arg in kwargs: send_kwargs[arg] = req_kwargs.pop(arg) if "timeout" not in send_kwargs: send_kwargs["timeout"] = 10 # Our special logging message parameter. if "message" in kwargs: message = kwargs.pop("message") else: message = "getting URL" req = requests.Request("GET", *args, **req_kwargs) with requests.Session() as s: s.headers = {"User-Agent": "beets"} prepped = s.prepare_request(req) settings = s.merge_environment_settings( prepped.url, {}, None, None, None ) send_kwargs.update(settings) log.debug("{}: {.url}", message, prepped) return s.send(prepped, **send_kwargs) class RequestMixin: """Adds a Requests wrapper to the class that uses the logger, which must be named `self._log`. """ _log: Logger def request(self, *args, **kwargs) -> requests.Response: """Like `requests.get`, but uses the logger `self._log`. See also `_logged_get`. """ return _logged_get(self._log, *args, **kwargs) # ART SOURCES ################################################################ class ArtSource(RequestMixin, ABC): # Specify whether this source fetches local or remote images LOC: ClassVar[SourceLocation] # A list of methods to match metadata, sorted by descending accuracy VALID_MATCHING_CRITERIA: ClassVar[list[str]] = ["default"] # A human-readable name for the art source NAME: ClassVar[str] # The key to select the art source in the config. This value will also be # stored in the database. ID: ClassVar[str] def __init__( self, log: Logger, config: confuse.ConfigView, match_by: None | list[str] = None, ) -> None: self._log = log self._config = config self.match_by = match_by or self.VALID_MATCHING_CRITERIA @cached_property def description(self) -> str: return f"{self.ID}[{', '.join(self.match_by)}]" @staticmethod def add_default_config(config: confuse.ConfigView) -> None: pass @classmethod def available(cls, log: Logger, config: confuse.ConfigView) -> bool: """Return whether or not all dependencies are met and the art source is in fact usable. """ return True @abstractmethod def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: pass def _candidate(self, **kwargs) -> Candidate: return Candidate(source_name=self.ID, log=self._log, **kwargs) @abstractmethod def fetch_image(self, candidate: Candidate, plugin: FetchArtPlugin) -> None: """Fetch the image to a temporary file if it is not already available as a local file. After calling this, `Candidate.path` is set to the image path if successful, or to `None` otherwise. """ pass def cleanup(self, candidate: Candidate) -> None: pass class LocalArtSource(ArtSource): LOC = "local" def fetch_image(self, candidate: Candidate, plugin: FetchArtPlugin) -> None: pass class RemoteArtSource(ArtSource): LOC = "remote" def fetch_image(self, candidate: Candidate, plugin: FetchArtPlugin) -> None: """Downloads an image from a URL and checks whether it seems to actually be an image. """ # This must only be called for candidates that were returned by # self.get, which are expected to have a url and no path (because they # haven't been downloaded yet). assert candidate.path is None assert candidate.url is not None if plugin.maxwidth: candidate.url = ArtResizer.shared.proxy_url( plugin.maxwidth, candidate.url ) try: with closing( self.request( candidate.url, stream=True, message="downloading image" ) ) as resp: ct = resp.headers.get("Content-Type", None) # Download the image to a temporary file. As some servers # (notably fanart.tv) have proven to return wrong Content-Types # when images were uploaded with a bad file extension, do not # rely on it. Instead validate the type using the file magic # and only then determine the extension. data = resp.iter_content(chunk_size=1024) header = b"" for chunk in data: header += chunk if len(header) >= 32: # The imghdr module will only read 32 bytes, and our # own additions in mediafile even less. break else: # server didn't return enough data, i.e. corrupt image return real_ct = image_mime_type(header) if real_ct is None: # detection by file magic failed, fall back to the # server-supplied Content-Type # Is our type detection failsafe enough to drop this? real_ct = ct if real_ct not in CONTENT_TYPES: self._log.debug( "not a supported image: {}", real_ct or "unknown content type", ) return ext = b"." + CONTENT_TYPES[real_ct][0] if real_ct != ct: self._log.warning( "Server specified {}, but returned a " "{} image. Correcting the extension " "to {}", ct, real_ct, ext, ) filename = get_temp_filename(__name__, suffix=ext.decode()) with open(filename, "wb") as fh: # write the first already loaded part of the image fh.write(header) # download the remaining part of the image for chunk in data: fh.write(chunk) self._log.debug( "downloaded art to: {}", util.displayable_path(filename) ) candidate.path = util.bytestring_path(filename) return except (OSError, requests.RequestException, TypeError) as exc: # Handling TypeError works around a urllib3 bug: # https://github.com/shazow/urllib3/issues/556 self._log.debug("error fetching art: {}", exc) return def cleanup(self, candidate: Candidate) -> None: if candidate.path: try: util.remove(path=candidate.path) except util.FilesystemError as exc: self._log.debug("error cleaning up tmp art: {}", exc) class CoverArtArchive(RemoteArtSource): NAME = "Cover Art Archive" ID = "coverart" VALID_MATCHING_CRITERIA: ClassVar[list[str]] = ["release", "releasegroup"] VALID_THUMBNAIL_SIZES: ClassVar[list[int]] = [250, 500, 1200] URL = "https://coverartarchive.org/release/{mbid}" GROUP_URL = "https://coverartarchive.org/release-group/{mbid}" def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: """Return the Cover Art Archive and Cover Art Archive release group URLs using album MusicBrainz release ID and release group ID. """ def get_image_urls( url: str, preferred_width: None | str = None, ) -> Iterator[str]: try: response = self.request(url) except requests.RequestException: self._log.debug("{.NAME}: error receiving response", self) return try: data = response.json() except ValueError: self._log.debug( "{.NAME}: error loading response: {.text}", self, response ) return for item in data.get("images", []): try: if "Front" not in item["types"]: continue # If there is a pre-sized thumbnail of the desired size # we select it. Otherwise, we return the raw image. image_url: str = item["image"] if preferred_width is not None: if isinstance(item.get("thumbnails"), dict): image_url = item["thumbnails"].get( preferred_width, image_url ) yield image_url except KeyError: pass release_url = self.URL.format(mbid=album.mb_albumid) release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid) # Cover Art Archive API offers pre-resized thumbnails at several sizes. # If the maxwidth config matches one of the already available sizes # fetch it directly instead of fetching the full sized image and # resizing it. preferred_width = None if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES: preferred_width = str(plugin.maxwidth) if "release" in self.match_by and album.mb_albumid: for url in get_image_urls(release_url, preferred_width): yield self._candidate(url=url, match=MetadataMatch.EXACT) if "releasegroup" in self.match_by and album.mb_releasegroupid: for url in get_image_urls(release_group_url, preferred_width): yield self._candidate(url=url, match=MetadataMatch.FALLBACK) class Amazon(RemoteArtSource): NAME = "Amazon" ID = "amazon" URL = "https://images.amazon.com/images/P/{}.{:02d}.LZZZZZZZ.jpg" INDICES = (1, 2) def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: """Generate URLs using Amazon ID (ASIN) string.""" if album.asin: for index in self.INDICES: yield self._candidate( url=self.URL.format(album.asin, index), match=MetadataMatch.EXACT, ) class AlbumArtOrg(RemoteArtSource): NAME = "AlbumArt.org scraper" ID = "albumart" URL = "https://www.albumart.org/index_detail.php" PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ): """Return art URL from AlbumArt.org using album ASIN.""" if not album.asin: return # Get the page from albumart.org. try: resp = self.request(self.URL, params={"asin": album.asin}) self._log.debug("scraped art URL: {.url}", resp) except requests.RequestException: self._log.debug("error scraping art page") return # Search the page for the image URL. m = re.search(self.PAT, resp.text) if m: image_url = m.group(1) yield self._candidate(url=image_url, match=MetadataMatch.EXACT) else: self._log.debug("no image found on page") class GoogleImages(RemoteArtSource): NAME = "Google Images" ID = "google" URL = "https://www.googleapis.com/customsearch/v1" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.key = (self._config["google_key"].get(),) self.cx = (self._config["google_engine"].get(),) @staticmethod def add_default_config(config: confuse.ConfigView): config.add( { "google_key": None, "google_engine": "001442825323518660753:hrh5ch1gjzm", } ) config["google_key"].redact = True config["google_engine"].redact = True @classmethod def available(cls, log: Logger, config: confuse.ConfigView) -> bool: has_key = bool(config["google_key"].get()) if not has_key: log.debug("google: Disabling art source due to missing key") return has_key def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: """Return art URL from google custom search engine given an album title and interpreter. """ if not (album.albumartist and album.album): return search_string = f"{album.albumartist},{album.album}".encode() try: response = self.request( self.URL, params={ "key": self.key, "cx": self.cx, "q": search_string, "searchType": "image", }, ) except requests.RequestException: self._log.debug("google: error receiving response") return # Get results using JSON. try: data = response.json() except ValueError: self._log.debug("google: error loading response: {.text}", response) return if "error" in data: reason = data["error"]["errors"][0]["reason"] self._log.debug("google fetchart error: {}", reason) return if "items" in data.keys(): for item in data["items"]: yield self._candidate( url=item["link"], match=MetadataMatch.EXACT ) class FanartTV(RemoteArtSource): """Art from fanart.tv requested using their API""" NAME = "fanart.tv" ID = "fanarttv" API_URL = "https://webservice.fanart.tv/v3/" API_ALBUMS = f"{API_URL}music/albums/" PROJECT_KEY = "61a7d0ab4e67162b7a0c7c35915cd48e" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.client_key = self._config["fanarttv_key"].get() @staticmethod def add_default_config(config: confuse.ConfigView): config.add( { "fanarttv_key": None, } ) config["fanarttv_key"].redact = True def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: if not album.mb_releasegroupid: return try: response = self.request( f"{self.API_ALBUMS}{album.mb_releasegroupid}", headers={ "api-key": self.PROJECT_KEY, "client-key": self.client_key, }, ) except requests.RequestException: self._log.debug("fanart.tv: error receiving response") return try: data = response.json() except ValueError: self._log.debug( "fanart.tv: error loading response: {.text}", response ) return if "status" in data and data["status"] == "error": if "not found" in data["error message"].lower(): self._log.debug("fanart.tv: no image found") elif "api key" in data["error message"].lower(): self._log.warning( "fanart.tv: Invalid API key given, please " "enter a valid one in your config file." ) else: self._log.debug( "fanart.tv: error on request: {}", data["error message"] ) return matches = [] # can there be more than one releasegroupid per response? for mbid, art in data.get("albums", {}).items(): # there might be more art referenced, e.g. cdart, and an albumcover # might not be present, even if the request was successful if album.mb_releasegroupid == mbid and "albumcover" in art: matches.extend(art["albumcover"]) # can this actually occur? else: self._log.debug( "fanart.tv: unexpected mb_releasegroupid in response!" ) matches.sort(key=lambda x: int(x["likes"]), reverse=True) for item in matches: # fanart.tv has a strict size requirement for album art to be # uploaded yield self._candidate( url=item["url"], match=MetadataMatch.EXACT, size=(1000, 1000) ) class ITunesStore(RemoteArtSource): NAME = "iTunes Store" ID = "itunes" API_URL = "https://itunes.apple.com/search" def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: """Return art URL from iTunes Store given an album title.""" if not (album.albumartist and album.album): return payload = { "term": f"{album.albumartist} {album.album}", "entity": "album", "media": "music", "limit": 200, } try: r = self.request(self.API_URL, params=payload) r.raise_for_status() except requests.RequestException as e: self._log.debug("iTunes search failed: {}", e) return try: candidates = r.json()["results"] except ValueError as e: self._log.debug("Could not decode json response: {}", e) return except KeyError as e: self._log.debug( "{} not found in json. Fields are {} ", e, list(r.json().keys()) ) return if not candidates: self._log.debug( "iTunes search for {!r} got no results", payload["term"] ) return if self._config["high_resolution"]: image_suffix = "100000x100000-999" else: image_suffix = "1200x1200bb" for c in candidates: try: if ( c["artistName"] == album.albumartist and c["collectionName"] == album.album ): art_url = c["artworkUrl100"] art_url = art_url.replace("100x100bb", image_suffix) yield self._candidate( url=art_url, match=MetadataMatch.EXACT ) except KeyError as e: self._log.debug( "Malformed itunes candidate: {} not found in {}", e, list(c.keys()), ) try: fallback_art_url = candidates[0]["artworkUrl100"] fallback_art_url = fallback_art_url.replace( "100x100bb", image_suffix ) yield self._candidate( url=fallback_art_url, match=MetadataMatch.FALLBACK ) except KeyError as e: self._log.debug( "Malformed itunes candidate: {} not found in {}", e, list(c.keys()), ) class Wikipedia(RemoteArtSource): NAME = "Wikipedia (queried through DBpedia)" ID = "wikipedia" DBPEDIA_URL = "https://dbpedia.org/sparql" WIKIPEDIA_URL = "https://en.wikipedia.org/w/api.php" SPARQL_QUERY = """PREFIX rdf: PREFIX dbpprop: PREFIX owl: PREFIX rdfs: PREFIX foaf: SELECT DISTINCT ?pageId ?coverFilename WHERE {{ ?subject owl:wikiPageID ?pageId . ?subject dbpprop:name ?name . ?subject rdfs:label ?label . {{ ?subject dbpprop:artist ?artist }} UNION {{ ?subject owl:artist ?artist }} {{ ?artist foaf:name "{artist}"@en }} UNION {{ ?artist dbpprop:name "{artist}"@en }} ?subject rdf:type . ?subject dbpprop:cover ?coverFilename . FILTER ( regex(?name, "{album}", "i") ) }} Limit 1""" def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: if not (album.albumartist and album.album): return # Find the name of the cover art filename on DBpedia cover_filename, page_id = None, None try: dbpedia_response = self.request( self.DBPEDIA_URL, params={ "format": "application/sparql-results+json", "timeout": 2500, "query": self.SPARQL_QUERY.format( artist=album.albumartist.title(), album=album.album ), }, headers={"content-type": "application/json"}, ) except requests.RequestException: self._log.debug("dbpedia: error receiving response") return try: data = dbpedia_response.json() results = data["results"]["bindings"] if results: cover_filename = f"File:{results[0]['coverFilename']['value']}" page_id = results[0]["pageId"]["value"] else: self._log.debug("wikipedia: album not found on dbpedia") except (ValueError, KeyError, IndexError): self._log.debug( "wikipedia: error scraping dbpedia response: {.text}", dbpedia_response, ) # Ensure we have a filename before attempting to query wikipedia if not (cover_filename and page_id): return # DBPedia sometimes provides an incomplete cover_filename, indicated # by the filename having a space before the extension, e.g., 'foo .bar' # An additional Wikipedia call can help to find the real filename. # This may be removed once the DBPedia issue is resolved, see: # https://github.com/dbpedia/extraction-framework/issues/396 if " ." in cover_filename and "." not in cover_filename.split(" .")[-1]: self._log.debug( "wikipedia: dbpedia provided incomplete cover_filename" ) lpart, rpart = cover_filename.rsplit(" .", 1) # Query all the images in the page try: wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ "format": "json", "action": "query", "continue": "", "prop": "images", "pageids": page_id, }, headers={"content-type": "application/json"}, ) except requests.RequestException: self._log.debug("wikipedia: error receiving response") return # Try to see if one of the images on the pages matches our # incomplete cover_filename try: data = wikipedia_response.json() results = data["query"]["pages"][page_id]["images"] for result in results: if re.match( rf"{re.escape(lpart)}.*?\.{re.escape(rpart)}", result["title"], ): cover_filename = result["title"] break except (ValueError, KeyError): self._log.debug( "wikipedia: failed to retrieve a cover_filename" ) return # Find the absolute url of the cover art on Wikipedia try: wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ "format": "json", "action": "query", "continue": "", "prop": "imageinfo", "iiprop": "url", "titles": cover_filename.encode("utf-8"), }, headers={"content-type": "application/json"}, ) except requests.RequestException: self._log.debug("wikipedia: error receiving response") return try: data = wikipedia_response.json() results = data["query"]["pages"] for _, result in results.items(): image_url = result["imageinfo"][0]["url"] yield self._candidate(url=image_url, match=MetadataMatch.EXACT) except (ValueError, KeyError, IndexError): self._log.debug("wikipedia: error scraping imageinfo") return class FileSystem(LocalArtSource): NAME = "Filesystem" ID = "filesystem" @staticmethod def filename_priority( filename: AnyStr, cover_names: Sequence[AnyStr] ) -> list[int]: """Sort order for image names. Return indexes of cover names found in the image filename. This means that images with lower-numbered and more keywords will have higher priority. """ return [idx for (idx, x) in enumerate(cover_names) if x in filename] def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: """Look for album art files in the specified directories.""" if not paths: return cover_names = list(map(util.bytestring_path, plugin.cover_names)) cover_names_str = b"|".join(cover_names) cover_pat = rb"".join([rb"(\b|_)(", cover_names_str, rb")(\b|_)"]) for path in paths: if not os.path.isdir(syspath(path)): continue # Find all files that look like images in the directory. images = [] ignore = config["ignore"].as_str_seq() ignore_hidden = config["ignore_hidden"].get(bool) for _, _, files in sorted_walk( path, ignore=ignore, ignore_hidden=ignore_hidden ): for fn in files: fn = bytestring_path(fn) for ext in IMAGE_EXTENSIONS: if fn.lower().endswith(b"." + ext) and os.path.isfile( syspath(os.path.join(path, fn)) ): images.append(fn) # Look for "preferred" filenames. images = sorted( images, key=lambda x: self.filename_priority(x, cover_names) ) remaining = [] for fn in images: if re.search(cover_pat, os.path.splitext(fn)[0], re.I): self._log.debug( "using well-named art file {}", util.displayable_path(fn), ) yield self._candidate( path=os.path.join(path, fn), match=MetadataMatch.EXACT ) else: remaining.append(fn) # Fall back to a configured image. if plugin.fallback: self._log.debug( "using fallback art file {}", util.displayable_path(plugin.fallback), ) yield self._candidate( path=plugin.fallback, match=MetadataMatch.FALLBACK ) # Fall back to any image in the folder. if remaining and not plugin.cautious: self._log.debug( "using fallback art file {}", util.displayable_path(remaining[0]), ) yield self._candidate( path=os.path.join(path, remaining[0]), match=MetadataMatch.FALLBACK, ) class LastFM(RemoteArtSource): NAME = "Last.fm" ID = "lastfm" # Sizes in priority order. SIZES: ClassVar[dict[str, tuple[int, int]]] = OrderedDict( [ ("mega", (300, 300)), ("extralarge", (300, 300)), ("large", (174, 174)), ("medium", (64, 64)), ("small", (34, 34)), ] ) API_URL = "https://ws.audioscrobbler.com/2.0" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.key = (self._config["lastfm_key"].get(),) @staticmethod def add_default_config(config: confuse.ConfigView) -> None: config.add( { "lastfm_key": None, } ) config["lastfm_key"].redact = True @classmethod def available(cls, log: Logger, config: confuse.ConfigView) -> bool: has_key = bool(config["lastfm_key"].get()) if not has_key: log.debug("lastfm: Disabling art source due to missing key") return has_key def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: if not album.mb_albumid: return try: response = self.request( self.API_URL, params={ "method": "album.getinfo", "api_key": self.key, "mbid": album.mb_albumid, "format": "json", }, ) except requests.RequestException: self._log.debug("lastfm: error receiving response") return try: data = response.json() if "error" in data: if data["error"] == 6: self._log.debug( "lastfm: no results for {.mb_albumid}", album ) else: self._log.error( "lastfm: failed to get album info: {} ({})", data["message"], data["error"], ) else: images = { image["size"]: image["#text"] for image in data["album"]["image"] } # Provide candidates in order of size. for size in self.SIZES.keys(): if size in images: yield self._candidate( url=images[size], size=self.SIZES[size] ) except ValueError: self._log.debug("lastfm: error loading response: {.text}", response) return class Spotify(RemoteArtSource): NAME = "Spotify" ID = "spotify" SPOTIFY_ALBUM_URL = "https://open.spotify.com/album/" @classmethod def available(cls, log: Logger, config: confuse.ConfigView) -> bool: if not HAS_BEAUTIFUL_SOUP: log.debug( "To use Spotify as an album art source, " "you must install the beautifulsoup4 module. See " "the documentation for further details." ) return HAS_BEAUTIFUL_SOUP def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: try: url = f"{self.SPOTIFY_ALBUM_URL}{album.items().get().spotify_album_id}" except AttributeError: self._log.debug("Fetchart: no Spotify album ID found") return try: response = requests.get(url, timeout=10) response.raise_for_status() except requests.RequestException as e: self._log.debug("Error: {!s}", e) return try: html = response.text soup = BeautifulSoup(html, "html.parser") except ValueError: self._log.debug( "Spotify: error loading response: {.text}", response ) return tag = soup.find("meta", attrs={"property": "og:image"}) if tag is None or not isinstance(tag, Tag): self._log.debug( "Spotify: Unexpected response, og:image tag missing" ) return image_url = tag["content"] yield self._candidate(url=image_url, match=MetadataMatch.EXACT) class CoverArtUrl(RemoteArtSource): # This source is intended to be used with a plugin that sets the # cover_art_url field on albums or tracks. Users can also manually update # the cover_art_url field using the "set" command. This source will then # use that URL to fetch the image. NAME = "Cover Art URL" ID = "cover_art_url" def get( self, album: Album, plugin: FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[Candidate]: image_url = None try: # look for cover_art_url on album or first track if album.get("cover_art_url"): image_url = album.cover_art_url else: image_url = album.items().get().cover_art_url self._log.debug("Cover art URL {} found for {}", image_url, album) except (AttributeError, TypeError): self._log.debug("Cover art URL not found for {}", album) return if image_url: yield self._candidate(url=image_url, match=MetadataMatch.EXACT) else: self._log.debug("Cover art URL not found for {}", album) return # All art sources. The order they will be tried in is specified by the config. ART_SOURCES: set[type[ArtSource]] = { FileSystem, CoverArtArchive, ITunesStore, AlbumArtOrg, Amazon, Wikipedia, GoogleImages, FanartTV, LastFM, Spotify, CoverArtUrl, } # PLUGIN LOGIC ############################################################### class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): PAT_PX = r"(0|[1-9][0-9]*)px" PAT_PERCENT = r"(100(\.00?)?|[1-9]?[0-9](\.[0-9]{1,2})?)%" def __init__(self) -> None: super().__init__() # Holds candidates corresponding to downloaded images between # fetching them and placing them in the filesystem. self.art_candidates: dict[ImportTask, Candidate] = {} self.config.add( { "auto": True, "minwidth": 0, "maxwidth": 0, "quality": 0, "max_filesize": 0, "enforce_ratio": False, "cautious": False, "cover_names": ["cover", "front", "art", "album", "folder"], "fallback": None, "sources": [ "filesystem", "coverart", "itunes", "amazon", "albumart", "cover_art_url", ], "store_source": False, "high_resolution": False, "deinterlace": False, "cover_format": None, } ) for source in ART_SOURCES: source.add_default_config(self.config) self.minwidth = self.config["minwidth"].get(int) self.maxwidth = self.config["maxwidth"].get(int) self.max_filesize = self.config["max_filesize"].get(int) self.quality = self.config["quality"].get(int) # allow both pixel and percentage-based margin specifications self.enforce_ratio = self.config["enforce_ratio"].get( confuse.OneOf[bool | str]( [ bool, confuse.String(pattern=self.PAT_PX), confuse.String(pattern=self.PAT_PERCENT), ] ) ) self.margin_px = None self.margin_percent = None self.deinterlace = self.config["deinterlace"].get(bool) if isinstance(self.enforce_ratio, str): if self.enforce_ratio[-1] == "%": self.margin_percent = float(self.enforce_ratio[:-1]) / 100 elif self.enforce_ratio[-2:] == "px": self.margin_px = int(self.enforce_ratio[:-2]) else: # shouldn't happen raise confuse.ConfigValueError() self.enforce_ratio = True cover_names = self.config["cover_names"].as_str_seq() self.cover_names = list(map(util.bytestring_path, cover_names)) self.cautious = self.config["cautious"].get(bool) self.fallback = self.config["fallback"].get( confuse.Optional(confuse.Filename()) ) self.store_source = self.config["store_source"].get(bool) self.cover_format = self.config["cover_format"].get( confuse.Optional(str) ) if self.config["auto"]: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] self.register_listener("import_task_files", self.assign_art) available_sources = [ (s_cls.ID, c) for s_cls in ART_SOURCES if s_cls.available(self._log, self.config) for c in s_cls.VALID_MATCHING_CRITERIA ] sources = sanitize_pairs( self.config["sources"].as_pairs(default_value="*"), available_sources, ) if "remote_priority" in self.config: self._log.warning( "The `fetch_art.remote_priority` configuration option has " "been deprecated. Instead, place `filesystem` at the end of " "your `sources` list." ) if self.config["remote_priority"].get(bool): fs = [] others = [] for s, c in sources: if s == "filesystem": fs.append((s, c)) else: others.append((s, c)) sources = others + fs sources_by_name = {s_cls.ID: s_cls for s_cls in ART_SOURCES} self.sources = [ sources_by_name[s](self._log, self.config, match_by=[c]) for s, c in sources ] @staticmethod def _is_source_file_removal_enabled() -> bool: return config["import"]["delete"].get(bool) or config["import"][ "move" ].get(bool) def _is_candidate_fallback(self, candidate: Candidate) -> bool: try: return ( candidate.path is not None and self.fallback is not None and os.path.samefile(candidate.path, self.fallback) ) except OSError: return False # Asynchronous; after music is added to the library. def fetch_art(self, session: ImportSession, task: ImportTask) -> None: """Find art for the album being imported.""" if task.is_album: # Only fetch art for full albums. if task.album.artpath and os.path.isfile( syspath(task.album.artpath) ): # Album already has art (probably a re-import); skip it. return if task.choice_flag == importer.Action.ASIS: # For as-is imports, don't search Web sources for art. local = True elif task.choice_flag in ( importer.Action.APPLY, importer.Action.RETAG, ): # Search everywhere for art. local = False else: # For any other choices (e.g., TRACKS), do nothing. return candidate = self.art_for_album(task.album, task.paths, local) if candidate: self.art_candidates[task] = candidate def _set_art( self, album: Album, candidate: Candidate, delete: bool = False ) -> None: album.set_art(candidate.path, delete) if self.store_source: # store the source of the chosen artwork in a flexible field self._log.debug( "Storing art_source for {0.albumartist} - {0.album}", album ) album.art_source = candidate.source_name album.store() # Synchronous; after music files are put in place. def assign_art(self, session: ImportSession, task: ImportTask): """Place the discovered art in the filesystem.""" if task in self.art_candidates: candidate = self.art_candidates.pop(task) removal_enabled = self._is_source_file_removal_enabled() self._set_art(task.album, candidate, not removal_enabled) if removal_enabled and not self._is_candidate_fallback(candidate): task.prune(candidate.path) # Manual album art fetching. def commands(self) -> list[ui.Subcommand]: cmd = ui.Subcommand("fetchart", help="download album art") cmd.parser.add_option( "-f", "--force", dest="force", action="store_true", default=False, help="re-download art when already present", ) cmd.parser.add_option( "-q", "--quiet", dest="quiet", action="store_true", default=False, help="quiet mode: do not output albums that already have artwork", ) def func(lib: Library, opts, args) -> None: self.batch_fetch_art(lib, lib.albums(args), opts.force, opts.quiet) cmd.func = func return [cmd] # Utilities converted from functions to methods on logging overhaul def art_for_album( self, album: Album, paths: None | Sequence[bytes], local_only: bool = False, ) -> None | Candidate: """Given an Album object, returns a path to downloaded art for the album (or None if no art is found). If `maxwidth`, then images are resized to this maximum pixel size. If `quality` then resized images are saved at the specified quality level. If `local_only`, then only local image files from the filesystem are returned; no network requests are made. """ out = None for source in self.sources: if source.LOC == "local" or not local_only: self._log.debug( "trying source {0.description}" " for album {1.albumartist} - {1.album}", source, album, ) # URLs might be invalid at this point, or the image may not # fulfill the requirements for candidate in source.get(album, self, paths): source.fetch_image(candidate, self) if candidate.validate(self) != ImageAction.BAD: out = candidate assert out.path is not None # help mypy self._log.debug( "using {.LOC} image {.path}", source, out ) break # Remove temporary files for invalid candidates. source.cleanup(candidate) if out: break if out: out.resize(self) return out def batch_fetch_art( self, lib: Library, albums: Iterable[Album], force: bool, quiet: bool, ) -> None: """Fetch album art for each of the albums. This implements the manual fetchart CLI command. """ for album in albums: if ( album.artpath and not force and os.path.isfile(syspath(album.artpath)) ): if not quiet: message = colorize("text_highlight_minor", "has album art") ui.print_(f"{album}: {message}") else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web # sources. local_paths = None if force else [album.path] candidate = self.art_for_album(album, local_paths) if candidate: self._set_art(album, candidate) message = colorize("text_success", "found album art") else: message = colorize("text_error", "no art found") ui.print_(f"{album}: {message}") ================================================ FILE: beetsplug/filefilter.py ================================================ # This file is part of beets. # Copyright 2016, Malte Ried. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Filter imported files using a regular expression.""" import re from beets import config from beets.importer import SingletonImportTask from beets.plugins import BeetsPlugin from beets.util import bytestring_path class FileFilterPlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "import_task_created", self.import_task_created_event ) self.config.add({"path": ".*"}) self.path_album_regex = self.path_singleton_regex = re.compile( bytestring_path(self.config["path"].get()) ) if "album_path" in self.config: self.path_album_regex = re.compile( bytestring_path(self.config["album_path"].get()) ) if "singleton_path" in self.config: self.path_singleton_regex = re.compile( bytestring_path(self.config["singleton_path"].get()) ) def import_task_created_event(self, session, task): if task.items and len(task.items) > 0: items_to_import = [] for item in task.items: if self.file_filter(item["path"]): items_to_import.append(item) if len(items_to_import) > 0: task.items = items_to_import else: # Returning an empty list of tasks from the handler # drops the task from the rest of the importer pipeline. return [] elif isinstance(task, SingletonImportTask): if not self.file_filter(task.item["path"]): return [] # If not filtered, return the original task unchanged. return [task] def file_filter(self, full_path): """Checks if the configured regular expressions allow the import of the file given in full_path. """ import_config = dict(config["import"]) full_path = bytestring_path(full_path) if "singletons" not in import_config or not import_config["singletons"]: # Album return self.path_album_regex.match(full_path) is not None else: # Singleton return self.path_singleton_regex.match(full_path) is not None ================================================ FILE: beetsplug/fish.py ================================================ # This file is part of beets. # Copyright 2015, winters jean-marie. # Copyright 2020, Justin Mayer # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This plugin generates tab completions for Beets commands for the Fish shell , including completions for Beets commands, plugin commands, and option flags. Also generated are completions for all the album and track fields, suggesting for example `genres:` or `album:` when querying the Beets database. Completions for the *values* of those fields are not generated by default but can be added via the `-e` / `--extravalues` flag. For example: `beet fish -e genres -e albumartist` """ import os from operator import attrgetter from beets import library, plugins, ui from beets.plugins import BeetsPlugin from beets.ui import commands BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n""" BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n""" BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n""" BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n""" HEAD = """ function __fish_beet_needs_command set cmd (commandline -opc) if test (count $cmd) -eq 1 return 0 end return 1 end function __fish_beet_using_command set cmd (commandline -opc) set needle (count $cmd) if test $needle -gt 1 if begin test $argv[1] = $cmd[2]; and not contains -- $cmd[$needle] $FIELDS; end return 0 end end return 1 end function __fish_beet_use_extra set cmd (commandline -opc) set needle (count $cmd) if test $argv[2] = $cmd[$needle] return 0 end return 1 end """ class FishPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand("fish", help="generate Fish shell tab completions") cmd.func = self.run cmd.parser.add_option( "-f", "--noFields", action="store_true", default=False, help="omit album/track field completions", ) cmd.parser.add_option( "-e", "--extravalues", action="append", type="choice", choices=library.Item.all_keys() + library.Album.all_keys(), help="include specified field *values* in completions", ) cmd.parser.add_option( "-o", "--output", default="~/.config/fish/completions/beet.fish", help=( "where to save the script. default: ~/.config/fish/completions" ), ) return [cmd] def run(self, lib, opts, args): # Gather the commands from Beets core and its plugins. # Collect the album and track fields. # If specified, also collect the values for these fields. # Make a giant string of all the above, formatted in a way that # allows Fish to do tab completion for the `beet` command. completion_file_path = os.path.expanduser(opts.output) completion_dir = os.path.dirname(completion_file_path) if completion_dir != "": os.makedirs(completion_dir, exist_ok=True) nobasicfields = opts.noFields # Do not complete for album/track fields extravalues = opts.extravalues # e.g., Also complete artists names beetcmds = sorted( (commands.default_commands + plugins.commands()), key=attrgetter("name"), ) fields = sorted(set(library.Album.all_keys() + library.Item.all_keys())) # Collect commands, their aliases, and their help text cmd_names_help = [] for cmd in beetcmds: names = list(cmd.aliases) names.append(cmd.name) for name in names: cmd_names_help.append((name, cmd.help)) # Concatenate the string totstring = f"{HEAD}\n" totstring += get_cmds_list([name[0] for name in cmd_names_help]) totstring += "" if nobasicfields else get_standard_fields(fields) totstring += get_extravalues(lib, extravalues) if extravalues else "" totstring += "\n# ====== setup basic beet completion =====\n\n" totstring += get_basic_beet_options() totstring += "\n# ====== setup field completion for subcommands =====\n" totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues) # Set up completion for all the command options totstring += get_all_commands(beetcmds) with open(completion_file_path, "w") as fish_file: fish_file.write(totstring) def _escape(name): # Escape ? in fish if name == "?": name = f"\\{name}" return name def get_cmds_list(cmds_names): # Make a list of all Beets core & plugin commands return f"set CMDS {' '.join(cmds_names)}\n\n" def get_standard_fields(fields): # Make a list of album/track fields and append with ':' fields = (f"{field}:" for field in fields) return f"set FIELDS {' '.join(fields)}\n\n" def get_extravalues(lib, extravalues): # Make a list of all values from an album/track field. # 'beet ls albumartist: ' yields completions for ABBA, Beatles, etc. word = "" values_set = get_set_of_values_for_field(lib, extravalues) for fld in extravalues: extraname = f"{fld.upper()}S" word += f"set {extraname} {' '.join(sorted(values_set[fld]))}\n\n" return word def get_set_of_values_for_field(lib, fields): # Get unique values from a specified album/track field fields_dict = {} for each in fields: fields_dict[each] = set() for item in lib.items(): for field in fields: fields_dict[field].add(wrap(item[field])) return fields_dict def get_basic_beet_options(): word = ( BL_NEED2.format("-l format-item", "-f -d 'print with custom format'") + BL_NEED2.format("-l format-album", "-f -d 'print with custom format'") + BL_NEED2.format( "-s l -l library", "-F -r -d 'library database file to use'" ) + BL_NEED2.format( "-s d -l directory", "-F -r -d 'destination music directory'" ) + BL_NEED2.format( "-s v -l verbose", "-f -d 'print debugging information'" ) + BL_NEED2.format( "-s c -l config", "-F -r -d 'path to configuration file'" ) + BL_NEED2.format( "-s h -l help", "-f -d 'print this help message and exit'" ) ) return word def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): # Formatting for Fish to complete our fields/values word = "" for cmdname, cmdhelp in cmd_name_and_help: cmdname = _escape(cmdname) word += f"\n# ------ fieldsetups for {cmdname} -------\n" word += BL_NEED2.format( f"-a {cmdname}", f"-f -d {wrap(clean_whitespace(cmdhelp))}" ) if nobasicfields is False: word += BL_USE3.format( cmdname, f"-a {wrap('$FIELDS')}", f"-d {wrap('fieldname')}", ) if extravalues: for f in extravalues: setvar = wrap(f"${f.upper()}S") word += " ".join( BL_EXTRA3.format( f"{cmdname} {f}:", f"-f -A -a {setvar}", f"-d {wrap(f)}", ).split() ) word += "\n" return word def get_all_commands(beetcmds): # Formatting for Fish to complete command options word = "" for cmd in beetcmds: names = list(cmd.aliases) names.append(cmd.name) for name in names: name = _escape(name) word += f"\n\n\n# ====== completions for {name} =====\n" for option in cmd.parser._get_all_options()[1:]: cmd_l = ( f" -l {option._long_opts[0].replace('--', '')}" if option._long_opts else "" ) cmd_s = ( f" -s {option._short_opts[0].replace('-', '')}" if option._short_opts else "" ) cmd_need_arg = " -r " if option.nargs in [1] else "" cmd_helpstr = ( f" -d {wrap(' '.join(option.help.split()))}" if option.help else "" ) cmd_arglist = ( f" -a {wrap(' '.join(option.choices))}" if option.choices else "" ) word += " ".join( BL_USE3.format( name, f"{cmd_need_arg}{cmd_s}{cmd_l} {cmd_arglist}", cmd_helpstr, ).split() ) word += "\n" word = word + BL_USE3.format( name, "-s h -l help", f"-d {wrap('print help')}", ) return word def clean_whitespace(word): # Remove excess whitespace and tabs in a string return " ".join(word.split()) def wrap(word): # Need " or ' around strings but watch out if they're in the string sptoken = '"' if '"' in word and ("'") in word: word.replace('"', sptoken) return f'"{word}"' tok = '"' if "'" in word else "'" return f"{tok}{word}{tok}" ================================================ FILE: beetsplug/freedesktop.py ================================================ # This file is part of beets. # Copyright 2016, Matt Lichtenberg. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Creates freedesktop.org-compliant .directory files on an album level.""" from beets import ui from beets.plugins import BeetsPlugin class FreedesktopPlugin(BeetsPlugin): def commands(self): deprecated = ui.Subcommand( "freedesktop", help="Print a message to redirect to thumbnails --dolphin", ) deprecated.func = self.deprecation_message return [deprecated] def deprecation_message(self, lib, opts, args): ui.print_( "This plugin is deprecated. Its functionality is " "superseded by the 'thumbnails' plugin" ) ui.print_( "'thumbnails --dolphin' replaces freedesktop. See doc & " "changelog for more information" ) ================================================ FILE: beetsplug/fromfilename.py ================================================ # This file is part of beets. # Copyright 2016, Jan-Erik Dahlin # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """If the title is empty, try to extract it from the filename (possibly also extract track and artist) """ import os import re from beets import plugins from beets.util import displayable_path # Filename field extraction patterns. PATTERNS = [ # Useful patterns. ( r"^(?P\d+)\.?\s*-\s*(?P.+?)\s*-\s*(?P.+?)" r"(\s*-\s*(?P<tag>.*))?$" ), r"^(?P<artist>.+?)\s*-\s*(?P<title>.+?)(\s*-\s*(?P<tag>.*))?$", r"^(?P<track>\d+)\.?[\s_-]+(?P<title>.+)$", r"^(?P<title>.+) by (?P<artist>.+)$", r"^(?P<track>\d+).*$", r"^(?P<title>.+)$", ] # Titles considered "empty" and in need of replacement. BAD_TITLE_PATTERNS = [ r"^$", ] def equal(seq): """Determine whether a sequence holds identical elements.""" return len(set(seq)) <= 1 def equal_fields(matchdict, field): """Do all items in `matchdict`, whose values are dictionaries, have the same value for `field`? (If they do, the field is probably not the title.) """ return equal(m[field] for m in matchdict.values()) def all_matches(names, pattern): """If all the filenames in the item/filename mapping match the pattern, return a dictionary mapping the items to dictionaries giving the value for each named subpattern in the match. Otherwise, return None. """ matches = {} for item, name in names.items(): m = re.match(pattern, name, re.IGNORECASE) if m and m.groupdict(): # Only yield a match when the regex applies *and* has # capture groups. Otherwise, no information can be extracted # from the filename. matches[item] = m.groupdict() else: return None return matches def bad_title(title): """Determine whether a given title is "bad" (empty or otherwise meaningless) and in need of replacement. """ for pat in BAD_TITLE_PATTERNS: if re.match(pat, title, re.IGNORECASE): return True return False def apply_matches(d, log): """Given a mapping from items to field dicts, apply the fields to the objects. """ some_map = next(iter(d.values())) keys = some_map.keys() # Only proceed if the "tag" field is equal across all filenames. if "tag" in keys and not equal_fields(d, "tag"): return # Given both an "artist" and "title" field, assume that one is # *actually* the artist, which must be uniform, and use the other # for the title. This, of course, won't work for VA albums. # Only check for "artist": patterns containing it, also contain "title" if "artist" in keys: if equal_fields(d, "artist"): artist = some_map["artist"] title_field = "title" elif equal_fields(d, "title"): artist = some_map["title"] title_field = "artist" else: # Both vary. Abort. return for item in d: if not item.artist: item.artist = artist log.info("Artist replaced with: {.artist}", item) # otherwise, if the pattern contains "title", use that for title_field elif "title" in keys: title_field = "title" else: title_field = None # Apply the title and track, if any. for item in d: if title_field and bad_title(item.title): item.title = str(d[item][title_field]) log.info("Title replaced with: {.title}", item) if "track" in d[item] and item.track == 0: item.track = int(d[item]["track"]) log.info("Track replaced with: {.track}", item) # Plugin structure and hook into import process. class FromFilenamePlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener("import_task_start", self.filename_task) def filename_task(self, task, session): """Examine each item in the task to see if we can extract a title from the filename. Try to match all filenames to a number of regexps, starting with the most complex patterns and successively trying less complex patterns. As soon as all filenames match the same regex we can make an educated guess of which part of the regex that contains the title. """ items = task.items if task.is_album else [task.item] # Look for suspicious (empty or meaningless) titles. missing_titles = sum(bad_title(i.title) for i in items) if missing_titles: # Get the base filenames (no path or extension). names = {} for item in items: path = displayable_path(item.path) name, _ = os.path.splitext(os.path.basename(path)) names[item] = name # Look for useful information in the filenames. for pattern in PATTERNS: self._log.debug(f"Trying pattern: {pattern}") d = all_matches(names, pattern) if d: apply_matches(d, self._log) ================================================ FILE: beetsplug/ftintitle.py ================================================ # This file is part of beets. # Copyright 2016, Verrus, <github.com/Verrus/beets-plugin-featInTitle> # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Moves "featured" artists to the title from the artist field.""" from __future__ import annotations import re from functools import cached_property, lru_cache from typing import TYPE_CHECKING from beets import config, plugins, ui if TYPE_CHECKING: from beets.importer import ImportSession, ImportTask from beets.library import Album, Item DEFAULT_BRACKET_KEYWORDS: tuple[str, ...] = ( "abridged", "acapella", "club", "demo", "edit", "edition", "extended", "instrumental", "live", "mix", "radio", "release", "remaster", "remastered", "remix", "rmx", "unabridged", "unreleased", "version", "vip", ) def split_on_feat( artist: str, for_artist: bool = True, custom_words: list[str] | None = None, ) -> tuple[str, str | None]: """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ # Try explicit featuring tokens first (ft, feat, featuring, etc.) # to avoid splitting on generic separators like "&" when both are present regex_explicit = re.compile( plugins.feat_tokens(for_artist=False, custom_words=custom_words), re.IGNORECASE, ) parts = tuple(s.strip() for s in regex_explicit.split(artist, 1)) if len(parts) == 2: return parts # Try comma as separator # (e.g. "Alice, Bob & Charlie" where Bob and Charlie are featuring) if for_artist and "," in artist: comma_parts = artist.split(",", 1) return comma_parts[0].strip(), comma_parts[1].strip() # Fall back to all tokens including generic separators if no explicit match if for_artist: regex = re.compile( plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE ) parts = tuple(s.strip() for s in regex.split(artist, 1)) if len(parts) == 1: return parts[0], None else: assert len(parts) == 2 # help mypy out return parts def contains_feat(title: str, custom_words: list[str] | None = None) -> bool: """Determine whether the title contains a "featured" marker.""" return bool( re.search( plugins.feat_tokens(for_artist=False, custom_words=custom_words), title, flags=re.IGNORECASE, ) ) def find_feat_part( artist: str, albumartist: str | None, custom_words: list[str] | None = None, ) -> str | None: """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ # Handle a wider variety of extraction cases if the album artist is # contained within the track artist. if albumartist and albumartist in artist: albumartist_split = artist.split(albumartist, 1) # If the last element of the split (the right-hand side of the # album artist) is nonempty, then it probably contains the # featured artist. if albumartist_split[1] != "": # Extract the featured artist from the right-hand side. _, feat_part = split_on_feat( albumartist_split[1], custom_words=custom_words ) return feat_part # Otherwise, if there's nothing on the right-hand side, # look for a featuring artist on the left-hand side. else: lhs, _ = split_on_feat( albumartist_split[0], custom_words=custom_words ) if lhs: return lhs # Fall back to conservative handling of the track artist without relying # on albumartist, which covers compilations using a 'Various Artists' # albumartist and album tracks by a guest artist featuring a third artist. _, feat_part = split_on_feat(artist, False, custom_words) return feat_part def _album_artist_no_feat(album: Album) -> str: custom_words = config["ftintitle"]["custom_words"].as_str_seq() return split_on_feat(album["albumartist"], False, list(custom_words))[0] class FtInTitlePlugin(plugins.BeetsPlugin): @cached_property def bracket_keywords(self) -> list[str]: return self.config["bracket_keywords"].as_str_seq() @staticmethod @lru_cache(maxsize=256) def _bracket_position_pattern(keywords: tuple[str, ...]) -> re.Pattern[str]: """ Build a compiled regex to find the first bracketed segment that contains any of the provided keywords. Cached by keyword tuple to avoid recompiling on every track/title. """ kw_inner = "|".join(map(re.escape, keywords)) # If we have keywords, require one of them to appear in the bracket text. # If kw == "", the lookahead becomes true and we match any bracket content. kw = rf"\b(?={kw_inner})\b" if kw_inner else "" return re.compile( rf""" (?: # non-capturing group for the split \s*? # optional whitespace before brackets (?= # any bracket containing a keyword \([^)]*{kw}.*?\) | \[[^]]*{kw}.*?\] | <[^>]*{kw}.*? > | \{{[^}}]*{kw}.*?\}} | $ # or the end of the string ) ) """, re.IGNORECASE | re.VERBOSE, ) def __init__(self) -> None: super().__init__() self.config.add( { "auto": True, "drop": False, "format": "feat. {}", "keep_in_artist": False, "preserve_album_artist": True, "custom_words": [], "bracket_keywords": list(DEFAULT_BRACKET_KEYWORDS), } ) self._command = ui.Subcommand( "ftintitle", help="move featured artists to the title field" ) self._command.parser.add_option( "-d", "--drop", dest="drop", action="store_true", default=None, help="drop featuring from artists and ignore title update", ) if self.config["auto"]: self.import_stages = [self.imported] self.album_template_fields["album_artist_no_feat"] = ( _album_artist_no_feat ) def commands(self) -> list[ui.Subcommand]: def func(lib, opts, args): self.config.set_args(opts) drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get( bool ) custom_words = self.config["custom_words"].get(list) write = ui.should_write() for item in lib.items(args): if self.ft_in_title( item, drop_feat, keep_in_artist_field, preserve_album_artist, custom_words, ): item.store() if write: item.try_write() self._command.func = func return [self._command] def imported(self, session: ImportSession, task: ImportTask) -> None: """Import hook for moving featuring artist automatically.""" drop_feat = self.config["drop"].get(bool) keep_in_artist_field = self.config["keep_in_artist"].get(bool) preserve_album_artist = self.config["preserve_album_artist"].get(bool) custom_words = self.config["custom_words"].get(list) for item in task.imported_items(): if self.ft_in_title( item, drop_feat, keep_in_artist_field, preserve_album_artist, custom_words, ): item.store() def update_metadata( self, item: Item, feat_part: str, drop_feat: bool, keep_in_artist_field: bool, custom_words: list[str], ) -> None: """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. If `drop_feat` is set, then do not add the artist to the title; just remove it from the artist field. """ # In case the artist is kept, do not update the artist fields. if keep_in_artist_field: self._log.info( "artist: {.artist} (Not changing due to keep_in_artist)", item ) else: track_artist, _ = split_on_feat( item.artist, custom_words=custom_words ) self._log.info("artist: {0.artist} -> {1}", item, track_artist) item.artist = track_artist if item.artist_sort: # Just strip the featured artist from the sort name. item.artist_sort, _ = split_on_feat( item.artist_sort, custom_words=custom_words ) # Only update the title if it does not already contain a featured # artist and if we do not drop featuring information. if not drop_feat and not contains_feat(item.title, custom_words): feat_format = self.config["format"].as_str() formatted = feat_format.format(feat_part) new_title = self.insert_ft_into_title( item.title, formatted, self.bracket_keywords ) self._log.info("title: {.title} -> {}", item, new_title) item.title = new_title def ft_in_title( self, item: Item, drop_feat: bool, keep_in_artist_field: bool, preserve_album_artist: bool, custom_words: list[str], ) -> bool: """Look for featured artists in the item's artist fields and move them to the title. Returns: True if the item has been modified. False otherwise. """ artist = item.artist.strip() albumartist = item.albumartist.strip() # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. if preserve_album_artist and albumartist and artist == albumartist: return False _, featured = split_on_feat(artist, custom_words=custom_words) if not featured: return False self._log.info("{.filepath}", item) # Attempt to find the featured artist. feat_part = find_feat_part(artist, albumartist, custom_words) if not feat_part: self._log.info("no featuring artists found") return False # If we have a featuring artist, move it to the title. self.update_metadata( item, feat_part, drop_feat, keep_in_artist_field, custom_words ) return True @staticmethod def find_bracket_position( title: str, keywords: list[str] | None = None ) -> int | None: normalized = ( DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) ) pattern = FtInTitlePlugin._bracket_position_pattern(normalized) m: re.Match[str] | None = pattern.search(title) return m.start() if m else None @classmethod def insert_ft_into_title( cls, title: str, feat_part: str, keywords: list[str] | None = None ) -> str: """Insert featured artist before the first bracket containing remix/edit keywords if present. """ normalized = ( DEFAULT_BRACKET_KEYWORDS if keywords is None else tuple(keywords) ) pattern = cls._bracket_position_pattern(normalized) parts = pattern.split(title, maxsplit=1) return f" {feat_part} ".join(parts).strip() ================================================ FILE: beetsplug/fuzzy.py ================================================ # This file is part of beets. # Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides a fuzzy matching query.""" import difflib from beets import config from beets.dbcore.query import StringFieldQuery from beets.plugins import BeetsPlugin class FuzzyQuery(StringFieldQuery[str]): def __init__(self, field_name: str, pattern: str, *_) -> None: # Fuzzy matching is only available via `string_match`. super().__init__(field_name, pattern, fast=False) @classmethod def string_match(cls, pattern: str, val: str) -> bool: # smartcase if pattern.islower(): val = val.lower() query_matcher = difflib.SequenceMatcher(None, pattern, val) threshold = config["fuzzy"]["threshold"].as_number() # Adjust match threshold for the case that the pattern is shorter # than the value being matched. This allows the pattern to match # substrings of the value, not just the entire value. if len(pattern) < len(val): max_possible_ratio = 2 * len(pattern) / (len(pattern) + len(val)) threshold *= max_possible_ratio # If upper bound of the ratio meets threshold, then calculate # the actual ratio. if query_matcher.quick_ratio() >= threshold: return query_matcher.ratio() >= threshold return False class FuzzyPlugin(BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "prefix": "~", "threshold": 0.7, } ) def queries(self): prefix = self.config["prefix"].as_str() return {prefix: FuzzyQuery} ================================================ FILE: beetsplug/hook.py ================================================ # This file is part of beets. # Copyright 2015, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows custom commands to be run when an event is emitted by beets""" from __future__ import annotations import os import shlex import string import subprocess from typing import Any from beets.plugins import BeetsPlugin class BytesToStrFormatter(string.Formatter): """A variant of `string.Formatter` that converts `bytes` to `str`.""" def convert_field(self, value: Any, conversion: str | None) -> Any: """Converts the provided value given a conversion type. This method decodes the converted value using the formatter's coding. """ converted = super().convert_field(value, conversion) if isinstance(converted, bytes): return os.fsdecode(converted) return converted class HookPlugin(BeetsPlugin): """Allows custom commands to be run when an event is emitted by beets""" def __init__(self): super().__init__() self.config.add({"hooks": []}) hooks = self.config["hooks"].get(list) for hook_index in range(len(hooks)): hook = self.config["hooks"][hook_index] hook_event = hook["event"].as_str() hook_command = hook["command"].as_str() self.create_and_register_hook(hook_event, hook_command) def create_and_register_hook(self, event, command): def hook_function(**kwargs): if command is None or len(command) == 0: self._log.error('invalid command "{}"', command) return # For backwards compatibility, use a string formatter that decodes # bytes (in particular, paths) to strings. formatter = BytesToStrFormatter() command_pieces = [ formatter.format(piece, event=event, **kwargs) for piece in shlex.split(command) ] self._log.debug( 'running command "{}" for event {}', " ".join(command_pieces), event, ) try: subprocess.check_call(command_pieces) except subprocess.CalledProcessError as exc: self._log.error( "hook for {} exited with status {.returncode}", event, exc ) except OSError as exc: self._log.error("hook for {} failed: {}", event, exc) self.register_listener(event, hook_function) ================================================ FILE: beetsplug/ihate.py ================================================ # This file is part of beets. # Copyright 2016, Blemjhoo Tezoulbr <baobab@heresiarch.info>. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Warns you about things you hate (or even blocks import).""" from beets.importer import Action from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin __author__ = "baobab@heresiarch.info" __version__ = "2.0" def summary(task): """Given an ImportTask, produce a short string identifying the object. """ if task.is_album: return f"{task.cur_artist} - {task.cur_album}" else: return f"{task.item.artist} - {task.item.title}" class IHatePlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "import_task_choice", self.import_task_choice_event ) self.config.add( { "warn": [], "skip": [], } ) @classmethod def do_i_hate_this(cls, task, action_patterns): """Process group of patterns (warn or skip) and returns True if task is hated and not whitelisted. """ if action_patterns: for query_string in action_patterns: query, _ = parse_query_string( query_string, Album if task.is_album else Item, ) if any(query.match(item) for item in task.imported_items()): return True return False def import_task_choice_event(self, session, task): skip_queries = self.config["skip"].as_str_seq() warn_queries = self.config["warn"].as_str_seq() if task.choice_flag == Action.APPLY: if skip_queries or warn_queries: self._log.debug("processing your hate") if self.do_i_hate_this(task, skip_queries): task.choice_flag = Action.SKIP self._log.info("skipped: {}", summary(task)) return if self.do_i_hate_this(task, warn_queries): self._log.info("you may hate this: {}", summary(task)) else: self._log.debug("nothing to do") else: self._log.debug("user made a decision, nothing to do") ================================================ FILE: beetsplug/importadded.py ================================================ """Populate an item's `added` and `mtime` fields by using the file modification time (mtime) of the item's source file before import. Reimported albums and items are skipped. """ import os from beets import importer, util from beets.plugins import BeetsPlugin class ImportAddedPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "preserve_mtimes": False, "preserve_write_mtimes": False, } ) # item.id for new items that were reimported self.reimported_item_ids = None # album.path for old albums that were replaced by a reimported album self.replaced_album_paths = None # item path in the library to the mtime of the source file self.item_mtime = {} register = self.register_listener register("import_task_created", self.check_config) register("import_task_created", self.record_if_inplace) register("import_task_files", self.record_reimported) register("before_item_moved", self.record_import_mtime) register("item_copied", self.record_import_mtime) register("item_linked", self.record_import_mtime) register("item_hardlinked", self.record_import_mtime) register("item_reflinked", self.record_import_mtime) register("album_imported", self.update_album_times) register("item_imported", self.update_item_times) register("after_write", self.update_after_write_time) def check_config(self, task, session): self.config["preserve_mtimes"].get(bool) def reimported_item(self, item): return item.id in self.reimported_item_ids def reimported_album(self, album): return album.path in self.replaced_album_paths def record_if_inplace(self, task, session): if not ( session.config["copy"] or session.config["move"] or session.config["link"] or session.config["hardlink"] or session.config["reflink"] ): self._log.debug( "In place import detected, recording mtimes from source paths" ) items = ( [task.item] if isinstance(task, importer.SingletonImportTask) else task.items ) for item in items: self.record_import_mtime(item, item.path, item.path) def record_reimported(self, task, session): self.reimported_item_ids = { item.id for item, replaced_items in task.replaced_items.items() if replaced_items } self.replaced_album_paths = set(task.replaced_albums.keys()) def write_file_mtime(self, path, mtime): """Write the given mtime to the destination path.""" stat = os.stat(util.syspath(path)) os.utime(util.syspath(path), (stat.st_atime, mtime)) def write_item_mtime(self, item, mtime): """Write the given mtime to an item's `mtime` field and to the mtime of the item's file. """ # The file's mtime on disk must be in sync with the item's mtime self.write_file_mtime(util.syspath(item.path), mtime) item.mtime = mtime def record_import_mtime(self, item, source, destination): """Record the file mtime of an item's path before its import.""" mtime = os.stat(util.syspath(source)).st_mtime self.item_mtime[destination] = mtime self._log.debug( "Recorded mtime {} for item '{}' imported from '{}'", mtime, util.displayable_path(destination), util.displayable_path(source), ) def update_album_times(self, lib, album): if self.reimported_album(album): self._log.debug( "Album '{.filepath}' is reimported, skipping import of " "added dates for the album and its items.", album, ) return album_mtimes = [] for item in album.items(): mtime = self.item_mtime.pop(item.path, None) if mtime: album_mtimes.append(mtime) if self.config["preserve_mtimes"].get(bool): self.write_item_mtime(item, mtime) item.store() album.added = min(album_mtimes) self._log.debug( "Import of album '{0.album}', selected album.added={0.added} " "from item file mtimes.", album, ) album.store() def update_item_times(self, lib, item): if self.reimported_item(item): self._log.debug( "Item '{.filepath}' is reimported, skipping import of added date.", item, ) return mtime = self.item_mtime.pop(item.path, None) if mtime: item.added = mtime if self.config["preserve_mtimes"].get(bool): self.write_item_mtime(item, mtime) self._log.debug( "Import of item '{0.filepath}', selected item.added={0.added}", item, ) item.store() def update_after_write_time(self, item, path): """Update the mtime of the item's file with the item.added value after each write of the item if `preserve_write_mtimes` is enabled. """ if item.added: if self.config["preserve_write_mtimes"].get(bool): self.write_item_mtime(item, item.added) self._log.debug( "Write of item '{0.filepath}', selected item.added={0.added}", item, ) ================================================ FILE: beetsplug/importfeeds.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Write paths of imported files in various formats to ease later import in a music player. Also allow printing the new file locations to stdout in case one wants to manually add music to a player by its path. """ import datetime import os import re from beets import config from beets.plugins import BeetsPlugin from beets.util import bytestring_path, link, mkdirall, normpath, syspath M3U_DEFAULT_NAME = "imported.m3u" def _build_m3u_session_filename(basename): """Builds unique m3u filename by putting current date between given basename and file ending.""" date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M") basename = re.sub(r"(\.m3u|\.M3U)", "", basename) path = normpath( os.path.join( config["importfeeds"]["dir"].as_filename(), f"{basename}_{date}.m3u" ) ) return path def _build_m3u_filename(basename): """Builds unique m3u filename by appending given basename to current date.""" basename = re.sub(r"[\s,/\\'\"]", "_", basename) date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M") path = normpath( os.path.join( config["importfeeds"]["dir"].as_filename(), f"{date}_{basename}.m3u", ) ) return path def _write_m3u(m3u_path, items_paths): """Append relative paths to items into m3u file.""" mkdirall(m3u_path) with open(syspath(m3u_path), "ab") as f: for path in items_paths: f.write(path + b"\n") class ImportFeedsPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "formats": [], "m3u_name": "imported.m3u", "dir": None, "relative_to": None, "absolute_path": False, } ) relative_to = self.config["relative_to"].get() if relative_to: self.config["relative_to"] = normpath(relative_to) else: self.config["relative_to"] = self.get_feeds_dir() self.register_listener("album_imported", self.album_imported) self.register_listener("item_imported", self.item_imported) self.register_listener("import_begin", self.import_begin) def get_feeds_dir(self): feeds_dir = self.config["dir"].get() if feeds_dir: return os.path.expanduser(bytestring_path(feeds_dir)) return config["directory"].as_filename() def _record_items(self, lib, basename, items): """Records relative paths to the given items for each feed format""" feedsdir = bytestring_path(self.get_feeds_dir()) formats = self.config["formats"].as_str_seq() relative_to = self.config["relative_to"].get() or self.get_feeds_dir() relative_to = bytestring_path(relative_to) paths = [] for item in items: if self.config["absolute_path"]: paths.append(item.path) else: try: relpath = os.path.relpath(item.path, relative_to) except ValueError: # On Windows, it is sometimes not possible to construct a # relative path (if the files are on different disks). relpath = item.path paths.append(relpath) if "m3u" in formats: m3u_basename = bytestring_path(self.config["m3u_name"].as_str()) m3u_path = os.path.join(feedsdir, m3u_basename) _write_m3u(m3u_path, paths) if "m3u_session" in formats: m3u_path = os.path.join(feedsdir, self.m3u_session) _write_m3u(m3u_path, paths) if "m3u_multi" in formats: m3u_path = _build_m3u_filename(basename) _write_m3u(m3u_path, paths) if "link" in formats: for path in paths: dest = os.path.join(feedsdir, os.path.basename(path)) if not os.path.exists(syspath(dest)): link(path, dest) if "echo" in formats: self._log.info("Location of imported music:") for path in paths: self._log.info(" {}", path) def album_imported(self, lib, album): self._record_items(lib, album.album, album.items()) def item_imported(self, lib, item): self._record_items(lib, item.title, [item]) def import_begin(self, session): formats = self.config["formats"].as_str_seq() if "m3u_session" in formats: self.m3u_session = _build_m3u_session_filename( self.config["m3u_name"].as_str() ) ================================================ FILE: beetsplug/importsource.py ================================================ """Adds a `source_path` attribute to imported albums indicating from what path the album was imported from. Also suggests removing that source path in case you've removed the album from the library. """ import os from pathlib import Path from shutil import rmtree from beets.dbcore.query import PathQuery from beets.plugins import BeetsPlugin from beets.ui import input_options from beets.util.color import colorize class ImportSourcePlugin(BeetsPlugin): """Main plugin class.""" def __init__(self): """Initialize the plugin and read configuration.""" super().__init__() self.config.add( { "suggest_removal": False, } ) self.import_stages = [self.import_stage] self.register_listener("item_removed", self.suggest_removal) # In order to stop future removal suggestions for an album we keep # track of `mb_albumid`s in this set. self.stop_suggestions_for_albums = set() # During reimports (import --library) both the import_task_choice and # the item_removed event are triggered. The item_removed event is # triggered first. For the import_task_choice event we prevent removal # suggestions using the existing stop_suggestions_for_album mechanism. self.register_listener( "import_task_choice", self.prevent_suggest_removal ) def prevent_suggest_removal(self, session, task): if task.skip: return for item in task.imported_items(): if "mb_albumid" in item: self.stop_suggestions_for_albums.add(item.mb_albumid) def import_stage(self, _, task): """Event handler for albums import finished.""" for item in task.imported_items(): # During reimports (import --library), we prevent overwriting the # source_path attribute with the path from the music library if "source_path" in item: self._log.info( "Preserving source_path of reimported item {}", item.id ) continue item["source_path"] = item.path item.try_sync(write=False, move=False) def suggest_removal(self, item): """Prompts the user to delete the original path the item was imported from.""" if ( not self.config["suggest_removal"] or item.mb_albumid in self.stop_suggestions_for_albums ): return if "source_path" not in item: self._log.warning( "Item without source_path (probably imported before plugin " "usage): {}", item.filepath, ) return srcpath = Path(os.fsdecode(item.source_path)) if not srcpath.is_file(): self._log.warning( "Original source file no longer exists or is not accessible: {}", srcpath, ) return if not ( os.access(srcpath, os.W_OK) and os.access(srcpath.parent, os.W_OK | os.X_OK) ): self._log.warning( "Original source file cannot be deleted (insufficient permissions): {}", srcpath, ) return # We ask the user whether they'd like to delete the item's source # directory item_path = colorize("text_warning", item.filepath) source_path = colorize("text_warning", srcpath) print( f"The item:\n{item_path}\nis originated from:\n{source_path}\n" "What would you like to do?" ) resp = input_options( [ "Delete the item's source", "Recursively delete the source's directory", "do Nothing", "do nothing and Stop suggesting to delete items from this album", ], require=True, ) # Handle user response if resp == "d": self._log.info( "Deleting the item's source file: {}", srcpath, ) srcpath.unlink() elif resp == "r": self._log.info( "Searching for other items with a source_path attr containing: {}", srcpath.parent, ) source_dir_query = PathQuery( "source_path", srcpath.parent, # The "source_path" attribute may not be present in all # items of the library, so we avoid errors with this: fast=False, ) print("Doing so will delete the following items' sources as well:") for searched_item in item._db.items(source_dir_query): print(colorize("text_warning", searched_item.filepath)) print("Would you like to continue?") continue_resp = input_options( ["Yes", "delete None", "delete just the File"], require=False, # Yes is the a default ) if continue_resp == "y": self._log.info( "Deleting the item's source directory: {}", srcpath.parent, ) rmtree(srcpath.parent) elif continue_resp == "n": self._log.info("doing nothing - aborting hook function") return elif continue_resp == "f": self._log.info( "removing just the item's original source: {}", srcpath, ) srcpath.unlink() elif resp == "s": self.stop_suggestions_for_albums.add(item.mb_albumid) else: self._log.info("Doing nothing") ================================================ FILE: beetsplug/info.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Shows file metadata.""" import os import mediafile from beets import ui from beets.library import Item from beets.plugins import BeetsPlugin from beets.util import displayable_path, normpath, syspath def tag_data(lib, args, album=False): query = [] for arg in args: path = normpath(arg) if os.path.isfile(syspath(path)): yield tag_data_emitter(path) else: query.append(arg) if query: for item in lib.items(query): yield tag_data_emitter(item.path) def tag_fields(): fields = set(mediafile.MediaFile.readable_fields()) fields.add("art") return fields def tag_data_emitter(path): def emitter(included_keys): if included_keys == "*": fields = tag_fields() else: fields = included_keys if "images" in fields: # We can't serialize the image data. fields.remove("images") mf = mediafile.MediaFile(syspath(path)) tags = {} for field in fields: if field == "art": tags[field] = mf.art is not None else: tags[field] = getattr(mf, field, None) # create a temporary Item to take advantage of __format__ item = Item.from_path(syspath(path)) return tags, item return emitter def library_data(lib, args, album=False): for item in lib.albums(args) if album else lib.items(args): yield library_data_emitter(item) def library_data_emitter(item): def emitter(included_keys): data = dict(item.formatted(included_keys=included_keys)) return data, item return emitter def update_summary(summary, tags): for key, value in tags.items(): if key not in summary: summary[key] = value elif summary[key] != value: summary[key] = "[various]" return summary def print_data(data, item=None, fmt=None): """Print, with optional formatting, the fields of a single element. If no format string `fmt` is passed, the entries on `data` are printed one in each line, with the format 'field: value'. If `fmt` is not `None`, the `item` is printed according to `fmt`, using the `Item.__format__` machinery. """ if fmt: # use fmt specified by the user ui.print_(format(item, fmt)) return path = displayable_path(item.path) if item else None formatted = {} for key, value in data.items(): if isinstance(value, list): formatted[key] = "; ".join(value) if value is not None: formatted[key] = value if len(formatted) == 0: return maxwidth = max(len(key) for key in formatted) if path: ui.print_(displayable_path(path)) for field in sorted(formatted): value = formatted[field] if isinstance(value, list): value = "; ".join(value) ui.print_(f"{field:>{maxwidth}}: {value}") def print_data_keys(data, item=None): """Print only the keys (field names) for an item.""" path = displayable_path(item.path) if item else None formatted = [] for key, value in data.items(): formatted.append(key) if len(formatted) == 0: return if path: ui.print_(displayable_path(path)) for field in sorted(formatted): ui.print_(f" {field}") class InfoPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand("info", help="show file metadata") cmd.func = self.run cmd.parser.add_option( "-l", "--library", action="store_true", help="show library fields instead of tags", ) cmd.parser.add_option( "-a", "--album", action="store_true", help='show album fields instead of tracks (implies "--library")', ) cmd.parser.add_option( "-s", "--summarize", action="store_true", help="summarize the tags of all files", ) cmd.parser.add_option( "-i", "--include-keys", default=[], action="append", dest="included_keys", help="comma separated list of keys to show", ) cmd.parser.add_option( "-k", "--keys-only", action="store_true", help="show only the keys", ) cmd.parser.add_format_option(target="item") return [cmd] def run(self, lib, opts, args): """Print tag info or library data for each file referenced by args. Main entry point for the `beet info ARGS...` command. If an argument is a path pointing to an existing file, then the tags of that file are printed. All other arguments are considered queries, and for each item matching all those queries the tags from the file are printed. If `opts.summarize` is true, the function merges all tags into one dictionary and only prints that. If two files have different values for the same tag, the value is set to '[various]' """ if opts.library or opts.album: data_collector = library_data else: data_collector = tag_data included_keys = [] for keys in opts.included_keys: included_keys.extend(keys.split(",")) # Drop path even if user provides it multiple times included_keys = [k for k in included_keys if k != "path"] first = True summary = {} for data_emitter in data_collector( lib, args, album=opts.album, ): try: data, item = data_emitter(included_keys or "*") except (mediafile.UnreadableFileError, OSError) as ex: self._log.error("cannot read file: {}", ex) continue if opts.summarize: update_summary(summary, data) else: if not first: ui.print_() if opts.keys_only: print_data_keys(data, item) else: fmt = [opts.format][0] if opts.format else None print_data(data, item, fmt) first = False if opts.summarize: print_data(summary) ================================================ FILE: beetsplug/inline.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows inline path template customization code in the config file.""" import itertools import traceback from beets import config from beets.plugins import BeetsPlugin FUNC_NAME = "__INLINE_FUNC__" class InlineError(Exception): """Raised when a runtime error occurs in an inline expression.""" def __init__(self, code, exc): super().__init__( f"error in inline path field code:\n{code}\n{type(exc).__name__}: {exc}" ) def _compile_func(body): """Given Python code for a function body, return a compiled callable that invokes that code. """ body = body.replace("\n", "\n ") body = f"def {FUNC_NAME}():\n {body}" code = compile(body, "inline", "exec") env = {} eval(code, env) return env[FUNC_NAME] class InlinePlugin(BeetsPlugin): def __init__(self): super().__init__() config.add( { "pathfields": {}, # Legacy name. "item_fields": {}, "album_fields": {}, } ) # Item fields. for key, view in itertools.chain( config["item_fields"].items(), config["pathfields"].items() ): self._log.debug("adding item field {}", key) func = self.compile_inline(view.as_str(), False, key) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config["album_fields"].items(): self._log.debug("adding album field {}", key) func = self.compile_inline(view.as_str(), True, key) if func is not None: self.album_template_fields[key] = func def compile_inline(self, python_code, album, field_name): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be compiled, then an error is logged and this function returns None. """ # First, try compiling as a single function. try: code = compile(f"({python_code})", "inline", "eval") except SyntaxError: # Fall back to a function body. try: func = _compile_func(python_code) except SyntaxError: self._log.error( "syntax error in inline field definition:\n{}", traceback.format_exc(), ) return else: is_expr = False else: is_expr = True def _dict_for(obj): out = {} for key in obj.keys(computed=False): if key == field_name: continue out[key] = obj._get(key) if album: out["items"] = list(obj.items()) return out if is_expr: # For expressions, just evaluate and return the result. def _expr_func(obj): values = _dict_for(obj) try: return eval(code, values) except Exception as exc: raise InlineError(python_code, exc) return _expr_func else: # For function bodies, invoke the function with values as global # variables. def _func_func(obj): old_globals = dict(func.__globals__) func.__globals__.update(_dict_for(obj)) try: return func() except Exception as exc: raise InlineError(python_code, exc) finally: func.__globals__.clear() func.__globals__.update(old_globals) return _func_func ================================================ FILE: beetsplug/ipfs.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds support for ipfs. Requires go-ipfs and a running ipfs daemon""" import os import shutil import subprocess import tempfile from beets import config, library, ui, util from beets.plugins import BeetsPlugin class IPFSPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "auto": True, "nocopy": False, } ) if self.config["auto"]: self.import_stages = [self.auto_add] def commands(self): cmd = ui.Subcommand("ipfs", help="interact with ipfs") cmd.parser.add_option( "-a", "--add", dest="add", action="store_true", help="Add to ipfs" ) cmd.parser.add_option( "-g", "--get", dest="get", action="store_true", help="Get from ipfs" ) cmd.parser.add_option( "-p", "--publish", dest="publish", action="store_true", help="Publish local library to ipfs", ) cmd.parser.add_option( "-i", "--import", dest="_import", action="store_true", help="Import remote library from ipfs", ) cmd.parser.add_option( "-l", "--list", dest="_list", action="store_true", help="Query imported libraries", ) cmd.parser.add_option( "-m", "--play", dest="play", action="store_true", help="Play music from remote libraries", ) def func(lib, opts, args): if opts.add: for album in lib.albums(args): if len(album.items()) == 0: self._log.info( "{} does not contain items, aborting", album ) self.ipfs_add(album) album.store() if opts.get: self.ipfs_get(lib, args) if opts.publish: self.ipfs_publish(lib) if opts._import: self.ipfs_import(lib, args) if opts._list: self.ipfs_list(lib, args) if opts.play: self.ipfs_play(lib, opts, args) cmd.func = func return [cmd] def auto_add(self, session, task): if task.is_album: if self.ipfs_add(task.album): task.album.store() def ipfs_play(self, lib, opts, args): from beetsplug.play import PlayPlugin jlib = self.get_remote_lib(lib) player = PlayPlugin() config["play"]["relative_to"] = None player.album = True player.play_music(jlib, player, args) def ipfs_add(self, album): try: album_dir = album.item_dir() except AttributeError: return False try: if album.ipfs: self._log.debug("{} already added", album_dir) # Already added to ipfs return False except AttributeError: pass self._log.info("Adding {} to ipfs", album_dir) if self.config["nocopy"]: cmd = "ipfs add --nocopy -q -r".split() else: cmd = "ipfs add -q -r".split() cmd.append(album_dir) try: output = util.command_output(cmd).stdout.split() except (OSError, subprocess.CalledProcessError) as exc: self._log.error("Failed to add {}, error: {}", album_dir, exc) return False length = len(output) for linenr, line in enumerate(output): line = line.strip() if linenr == length - 1: # last printed line is the album hash self._log.info("album: {}", line) album.ipfs = line else: try: item = album.items()[linenr] self._log.info("item: {}", line) item.ipfs = line item.store() except IndexError: # if there's non music files in the to-add folder they'll # get ignored here pass return True def ipfs_get(self, lib, query): query = query[0] # Check if query is a hash # TODO: generalize to other hashes; probably use a multihash # implementation if query.startswith("Qm") and len(query) == 46: self.ipfs_get_from_hash(lib, query) else: albums = self.query(lib, query) for album in albums: self.ipfs_get_from_hash(lib, album.ipfs) def ipfs_get_from_hash(self, lib, _hash): try: cmd = "ipfs get".split() cmd.append(_hash) util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: self._log.error( "Failed to get {} from ipfs.\n{.output}", _hash, err ) return False self._log.info("Getting {} from ipfs", _hash) imp = ui.commands.TerminalImportSession( lib, loghandler=None, query=None, paths=[_hash] ) imp.run() # This uses a relative path, hence we cannot use util.syspath(_hash, # prefix=True). However, that should be fine since the hash will not # exceed MAX_PATH. shutil.rmtree(util.syspath(_hash, prefix=False)) def ipfs_publish(self, lib): with tempfile.NamedTemporaryFile() as tmp: self.ipfs_added_albums(lib, tmp.name) try: if self.config["nocopy"]: cmd = "ipfs add --nocopy -q ".split() else: cmd = "ipfs add -q ".split() cmd.append(tmp.name) output = util.command_output(cmd).stdout except (OSError, subprocess.CalledProcessError) as err: msg = f"Failed to publish library. Error: {err}" self._log.error(msg) return False self._log.info("hash of library: {}", output) def ipfs_import(self, lib, args): _hash = args[0] if len(args) > 1: lib_name = args[1] else: lib_name = _hash lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") if not os.path.exists(remote_libs): try: os.makedirs(remote_libs) except OSError as e: msg = f"Could not create {remote_libs}. Error: {e}" self._log.error(msg) return False path = os.path.join(remote_libs, lib_name.encode() + b".db") if not os.path.exists(path): cmd = f"ipfs get {_hash} -o".split() cmd.append(path) try: util.command_output(cmd) except (OSError, subprocess.CalledProcessError): self._log.error("Could not import {}", _hash) return False # add all albums from remotes into a combined library jpath = os.path.join(remote_libs, b"joined.db") jlib = library.Library(jpath) nlib = library.Library(path) for album in nlib.albums(): if not self.already_added(album, jlib): new_album = [] for item in album.items(): item.id = None new_album.append(item) added_album = jlib.add_album(new_album) added_album.ipfs = album.ipfs added_album.store() def already_added(self, check, jlib): for jalbum in jlib.albums(): if jalbum.mb_albumid == check.mb_albumid: return True return False def ipfs_list(self, lib, args): fmt = config["format_album"].get() try: albums = self.query(lib, args) except OSError: ui.print_("No imported libraries yet.") return for album in albums: ui.print_(format(album, fmt), " : ", album.ipfs.decode()) def query(self, lib, args): rlib = self.get_remote_lib(lib) albums = rlib.albums(args) return albums def get_remote_lib(self, lib): lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") path = os.path.join(remote_libs, b"joined.db") if not os.path.isfile(path): raise OSError return library.Library(path) def ipfs_added_albums(self, rlib, tmpname): """Returns a new library with only albums/items added to ipfs""" tmplib = library.Library(tmpname) for album in rlib.albums(): try: if album.ipfs: self.create_new_album(album, tmplib) except AttributeError: pass return tmplib def create_new_album(self, album, tmplib): items = [] for item in album.items(): try: if not item.ipfs: break except AttributeError: pass item_path = os.fsdecode(os.path.basename(item.path)) # Clear current path from item item.path = f"/ipfs/{album.ipfs}/{item_path}" item.id = None items.append(item) if len(items) < 1: return False self._log.info("Adding '{}' to temporary library", album) new_album = tmplib.add_album(items) new_album.ipfs = album.ipfs new_album.store(inherit=False) ================================================ FILE: beetsplug/keyfinder.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Uses the `KeyFinder` program to add the `initial_key` field.""" import os.path import subprocess from beets import ui, util from beets.plugins import BeetsPlugin class KeyFinderPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "bin": "KeyFinder", "auto": True, "overwrite": False, } ) if self.config["auto"].get(bool): self.import_stages = [self.imported] def commands(self): cmd = ui.Subcommand( "keyfinder", help="detect and add initial key from audio" ) cmd.func = self.command return [cmd] def command(self, lib, opts, args): self.find_key(lib.items(args), write=ui.should_write()) def imported(self, session, task): self.find_key(task.imported_items()) def find_key(self, items, write=False): overwrite = self.config["overwrite"].get(bool) command = [self.config["bin"].as_str()] # The KeyFinder GUI program needs the -f flag before the path. # keyfinder-cli is similar, but just wants the path with no flag. if "keyfinder-cli" not in os.path.basename(command[0]).lower(): command.append("-f") for item in items: if item["initial_key"] and not overwrite: continue try: output = util.command_output( [*command, util.syspath(item.path)] ).stdout except (subprocess.CalledProcessError, OSError) as exc: self._log.error("execution failed: {}", exc) continue try: key_raw = output.rsplit(None, 1)[-1] except IndexError: # Sometimes keyfinder-cli returns 0 but with no key, usually # when the file is silent or corrupt, so we log and skip. self._log.error("no key returned for path: {.path}", item) continue try: key = key_raw.decode("utf-8") except UnicodeDecodeError: self._log.error("output is invalid UTF-8") continue item["initial_key"] = key self._log.info( "added computed initial key {} for {.filepath}", key, item ) if write: item.try_write() item.store() ================================================ FILE: beetsplug/kodiupdate.py ================================================ # This file is part of beets. # Copyright 2017, Pauli Kettunen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates a Kodi library whenever the beets library is changed. This is based on the Plex Update plugin. Put something like the following in your config.yaml to configure: kodi: host: localhost port: 8080 user: user pwd: secret """ import requests from beets.plugins import BeetsPlugin def update_kodi(host, port, user, password): """Sends request to the Kodi api to start a library refresh.""" url = f"http://{host}:{port}/jsonrpc" """Content-Type: application/json is mandatory according to the kodi jsonrpc documentation""" headers = {"Content-Type": "application/json"} # Create the payload. Id seems to be mandatory. payload = {"jsonrpc": "2.0", "method": "AudioLibrary.Scan", "id": 1} r = requests.post( url, auth=(user, password), json=payload, headers=headers, timeout=10, ) return r class KodiUpdate(BeetsPlugin): def __init__(self): super().__init__("kodi") # Adding defaults. self.config.add( [{"host": "localhost", "port": 8080, "user": "kodi", "pwd": "kodi"}] ) self.config["user"].redact = True self.config["pwd"].redact = True self.register_listener("database_change", self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update""" self.register_listener("cli_exit", self.update) def update(self, lib): """When the client exists try to send refresh request to Kodi server.""" self._log.info("Requesting a Kodi library update...") kodi = self.config.get() # Backwards compatibility in case not configured as an array if not isinstance(kodi, list): kodi = [kodi] for instance in kodi: # Try to send update request. try: r = update_kodi( instance["host"], instance["port"], instance["user"], instance["pwd"], ) r.raise_for_status() json = r.json() if json.get("result") != "OK": self._log.warning( "Kodi update failed: JSON response was {0!r}", json ) continue self._log.info( "Kodi update triggered for {}:{}", instance["host"], instance["port"], ) except requests.exceptions.RequestException as e: self._log.warning("Kodi update failed: {}", str(e)) continue ================================================ FILE: beetsplug/lastgenre/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Gets genres for imported music based on Last.fm tags. Uses a provided whitelist file to determine which tags are valid genres. The included (default) genre list was originally produced by scraping Wikipedia and has been edited to remove some questionable entries. The scraper script used is available here: https://gist.github.com/1241307 """ from __future__ import annotations import os from functools import singledispatchmethod from pathlib import Path from typing import TYPE_CHECKING, Any import yaml from beets import config, library, plugins, ui from beets.library import Album, Item from beets.util import plurality, unique_list from .client import LastFmClient if TYPE_CHECKING: import optparse from collections.abc import Iterable from beets.importer import ImportSession, ImportTask from beets.library import LibModel Whitelist = set[str] """Set of valid genre names (lowercase). Empty set means all genres allowed.""" CanonTree = list[list[str]] """Genre hierarchy as list of paths from general to specific. Example: [['electronic', 'house'], ['electronic', 'techno']]""" # Canonicalization tree processing. def flatten_tree( elem: dict[Any, Any] | list[Any] | str, path: list[str], branches: CanonTree, ) -> None: """Flatten nested lists/dictionaries into lists of strings (branches). """ if not path: path = [] if isinstance(elem, dict): for k, v in elem.items(): flatten_tree(v, [*path, k], branches) elif isinstance(elem, list): for sub in elem: flatten_tree(sub, path, branches) else: branches.append([*path, str(elem)]) def find_parents(candidate: str, branches: CanonTree) -> list[str]: """Find parents genre of a given genre, ordered from the closest to the further parent. """ for branch in branches: try: idx = branch.index(candidate.lower()) return list(reversed(branch[: idx + 1])) except ValueError: continue return [candidate] def get_depth(tag: str, branches: CanonTree) -> int | None: """Find the depth of a tag in the genres tree.""" for branch in branches: if tag in branch: return branch.index(tag) return None def sort_by_depth(tags: list[str], branches: CanonTree) -> list[str]: """Given a list of tags, sort the tags by their depths in the genre tree.""" depth_tag_pairs = [(get_depth(t, branches), t) for t in tags] depth_tag_pairs = [e for e in depth_tag_pairs if e[0] is not None] depth_tag_pairs.sort(reverse=True) return [p[1] for p in depth_tag_pairs] # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt") C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml") class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "whitelist": True, "min_weight": 10, "count": 1, "fallback": None, "canonical": False, "cleanup_existing": False, "source": "album", "force": False, "keep_existing": False, "auto": True, "prefer_specific": False, "title_case": True, "pretend": False, } ) self.setup() def setup(self) -> None: """Setup plugin from config options""" if self.config["auto"]: self.import_stages = [self.imported] self.client = LastFmClient( self._log, self.config["min_weight"].get(int) ) self.whitelist: Whitelist = self._load_whitelist() self.c14n_branches: CanonTree self.c14n_branches, self.canonicalize = self._load_c14n_tree() def _load_whitelist(self) -> Whitelist: """Load the whitelist from a text file. Default whitelist is used if config is True, empty string or set to "nothing". """ whitelist = set() wl_filename = self.config["whitelist"].get() if wl_filename in (True, "", None): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: self._log.debug("Loading whitelist {}", wl_filename) text = Path(wl_filename).expanduser().read_text(encoding="utf-8") for line in text.splitlines(): if (line := line.strip().lower()) and not line.startswith("#"): whitelist.add(line) return whitelist def _load_c14n_tree(self) -> tuple[CanonTree, bool]: """Load the canonicalization tree from a YAML file. Default tree is used if config is True, empty string, set to "nothing" or if prefer_specific is enabled. """ c14n_branches: CanonTree = [] c14n_filename = self.config["canonical"].get() canonicalize = c14n_filename is not False # Default tree if c14n_filename in (True, "", None) or ( # prefer_specific requires a tree, load default tree not canonicalize and self.config["prefer_specific"].get() ): c14n_filename = C14N_TREE # Read the tree if c14n_filename: self._log.debug("Loading canonicalization tree {}", c14n_filename) with Path(c14n_filename).expanduser().open(encoding="utf-8") as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], c14n_branches) return c14n_branches, canonicalize @property def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', 'album', or 'artist.' """ return self.config["source"].as_choice( { "track": ("track", "album", "artist"), "album": ("album", "artist"), "artist": ("artist",), } ) # Genre list processing. def _resolve_genres(self, tags: list[str]) -> list[str]: """Canonicalize, sort and filter a list of genres. - Returns an empty list if the input tags list is empty. - If canonicalization is enabled, it extends the list by incorporating parent genres from the canonicalization tree. When a whitelist is set, only parent tags that pass the whitelist filter are included; otherwise, it adds the oldest ancestor. Adding parent tags is stopped when the count of tags reaches the configured limit (count). - The tags list is then deduplicated to ensure only unique genres are retained. - If the 'prefer_specific' configuration is enabled, the list is sorted by the specificity (depth in the canonicalization tree) of the genres. - Finally applies whitelist filtering to ensure that only valid genres are kept. (This may result in no genres at all being retained). - Returns the filtered list of genres, limited to the configured count. """ if not tags: return [] count = self.config["count"].get(int) # Canonicalization (if enabled) if self.canonicalize: # Extend the list to consider tags parents in the c14n tree tags_all = [] for tag in tags: # Add parents that are in the whitelist, or add the oldest # ancestor if no whitelist if self.whitelist: parents = self._filter_valid( find_parents(tag, self.c14n_branches) ) else: parents = [find_parents(tag, self.c14n_branches)[-1]] tags_all += parents # Stop if we have enough tags already, unless we need to find # the most specific tag (instead of the most popular). if ( not self.config["prefer_specific"] and len(tags_all) >= count ): break tags = tags_all tags = unique_list(tags) # Sort the tags by specificity. if self.config["prefer_specific"]: tags = sort_by_depth(tags, self.c14n_branches) # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list valid_tags = self._filter_valid(tags) return valid_tags[:count] def _filter_valid(self, genres: Iterable[str]) -> list[str]: """Filter genres based on whitelist. Returns all genres if no whitelist is configured, otherwise returns only genres that are in the whitelist. """ # First, drop any falsy or whitespace-only genre strings to avoid # retaining empty tags from multi-valued fields. cleaned = [g for g in genres if g and g.strip()] if not self.whitelist: return cleaned return [g for g in cleaned if g.lower() in self.whitelist] # Genre resolution pipeline. def _format_genres(self, tags: list[str]) -> list[str]: """Format to title case if configured.""" if self.config["title_case"]: return [tag.title() for tag in tags] else: return tags def _get_existing_genres(self, obj: LibModel) -> list[str]: """Return a list of genres for this Item or Album.""" if isinstance(obj, library.Item): genres_list = obj.get("genres", with_album=False) else: genres_list = obj.get("genres") return genres_list def _combine_resolve_and_log( self, old: list[str], new: list[str] ) -> list[str]: """Combine old and new genres and process via _resolve_genres.""" self._log.debug("raw last.fm tags: {}", new) self._log.debug("existing genres taken into account: {}", old) combined = old + new return self._resolve_genres(combined) def _get_genre(self, obj: LibModel) -> tuple[list[str], str]: """Get the final genre list for an Album or Item object. `self.sources` specifies allowed genre sources. Starting with the first source in this tuple, the following stages run through until a genre is found or no options are left: - track (for Items only) - album - artist, albumartist or "most popular track genre" (for VA-albums) - original fallback - configured fallback - empty list A `(genres, label)` pair is returned, where `label` is a string used for logging. For example, "keep + artist, whitelist" indicates that existing genres were combined with new last.fm genres and whitelist filtering was applied, while "artist, any" means only new last.fm genres are included and the whitelist feature was disabled. """ def _try_resolve_stage( stage_label: str, keep_genres: list[str], new_genres: list[str] ) -> tuple[list[str], str] | None: """Try to resolve genres for a given stage and log the result.""" resolved_genres = self._combine_resolve_and_log( keep_genres, new_genres ) if resolved_genres: suffix = "whitelist" if self.whitelist else "any" label = f"{stage_label}, {suffix}" if keep_genres: label = f"keep + {label}" return self._format_genres(resolved_genres), label return None keep_genres = [] new_genres = [] genres = self._get_existing_genres(obj) if genres and not self.config["force"]: # Without force, but cleanup_existing enabled, we attempt # to canonicalize pre-populated tags before returning them. # If none are found, we use the fallback (if set). if self.config["cleanup_existing"]: keep_genres = [g.lower() for g in genres] if result := _try_resolve_stage("cleanup", keep_genres, []): return result # Return fallback string (None if not set). return self.config["fallback"].get(), "fallback" # If cleanup_existing is not set, the pre-populated tags are # returned as-is. return genres, "keep any, no-force" if self.config["force"]: # Force doesn't keep any unless keep_existing is set. # Whitelist validation is handled in _resolve_genres. if self.config["keep_existing"]: keep_genres = [g.lower() for g in genres] # Run through stages: track, album, artist, # album artist, or most popular track genre. if isinstance(obj, library.Item) and "track" in self.sources: if new_genres := self.client.fetch_track_genre( obj.artist, obj.title ): if result := _try_resolve_stage( "track", keep_genres, new_genres ): return result if "album" in self.sources: if new_genres := self.client.fetch_album_genre( obj.albumartist, obj.album ): if result := _try_resolve_stage( "album", keep_genres, new_genres ): return result if "artist" in self.sources: new_genres = [] if isinstance(obj, library.Item): new_genres = self.client.fetch_artist_genre(obj.artist) stage_label = "artist" elif obj.albumartist != config["va_name"].as_str(): new_genres = self.client.fetch_artist_genre(obj.albumartist) stage_label = "album artist" if not new_genres: self._log.extra_debug( 'No album artist genre found for "{}", ' "trying multi-valued field...", obj.albumartist, ) for albumartist in obj.albumartists: self._log.extra_debug( 'Fetching artist genre for "{}"', albumartist, ) new_genres += self.client.fetch_artist_genre( albumartist ) if new_genres: stage_label = "multi-valued album artist" else: # For "Various Artists", pick the most popular track genre. item_genres = [] assert isinstance(obj, Album) # Type narrowing for mypy for item in obj.items(): item_genre = None if "track" in self.sources: item_genre = self.client.fetch_track_genre( item.artist, item.title ) if not item_genre: item_genre = self.client.fetch_artist_genre(item.artist) if item_genre: item_genres += item_genre if item_genres: most_popular, rank = plurality(item_genres) new_genres = [most_popular] stage_label = "most popular track" self._log.debug( 'Most popular track genre "{}" ({}) for VA album.', most_popular, rank, ) if new_genres: if result := _try_resolve_stage( stage_label, keep_genres, new_genres ): return result # Nothing found, leave original if configured and valid. if genres and self.config["keep_existing"]: if valid_genres := self._filter_valid(genres): return valid_genres, "original fallback" # If the original genre doesn't match a whitelisted genre, check # if we can canonicalize it to find a matching, whitelisted genre! if result := _try_resolve_stage( "original fallback", keep_genres, [] ): return result # Return fallback as a list. if fallback := self.config["fallback"].get(): return [fallback], "fallback" # No fallback configured. return [], "fallback unconfigured" # Beets plugin hooks and CLI. def _fetch_and_log_genre(self, obj: LibModel) -> None: """Fetch genre and log it.""" self._log.info(str(obj)) obj.genres, label = self._get_genre(obj) self._log.debug("Resolved ({}): {}", label, obj.genres) ui.show_model_changes(obj, fields=["genres"], print_obj=False) @singledispatchmethod def _process(self, obj: LibModel, write: bool) -> None: """Process an object, dispatching to the appropriate method.""" raise NotImplementedError @_process.register def _process_track(self, obj: Item, write: bool) -> None: """Process a single track/item.""" self._fetch_and_log_genre(obj) if not self.config["pretend"]: obj.try_sync(write=write, move=False) @_process.register def _process_album(self, obj: Album, write: bool) -> None: """Process an entire album.""" self._fetch_and_log_genre(obj) if "track" in self.sources: for item in obj.items(): self._process(item, write) if not self.config["pretend"]: obj.try_sync( write=write, move=False, inherit="track" not in self.sources ) def commands(self) -> list[ui.Subcommand]: lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show actions but do nothing", ) lastgenre_cmd.parser.add_option( "-f", "--force", dest="force", action="store_true", help="modify existing genres", ) lastgenre_cmd.parser.add_option( "-F", "--no-force", dest="force", action="store_false", help="don't modify existing genres", ) lastgenre_cmd.parser.add_option( "-k", "--keep-existing", dest="keep_existing", action="store_true", help="combine with existing genres when modifying", ) lastgenre_cmd.parser.add_option( "-K", "--no-keep-existing", dest="keep_existing", action="store_false", help="don't combine with existing genres when modifying", ) lastgenre_cmd.parser.add_option( "-s", "--source", dest="source", type="string", help="genre source: artist, album, or track", ) lastgenre_cmd.parser.add_option( "-A", "--items", action="store_false", dest="album", help="match items instead of albums", ) lastgenre_cmd.parser.add_option( "-a", "--albums", action="store_true", dest="album", help="match albums instead of items (default)", ) lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func( lib: library.Library, opts: optparse.Values, args: list[str] ) -> None: self.config.set_args(opts) method = lib.albums if opts.album else lib.items for obj in method(args): self._process(obj, write=ui.should_write()) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] def imported(self, _: ImportSession, task: ImportTask) -> None: self._process(task.album if task.is_album else task.item, write=False) # type: ignore[attr-defined] ================================================ FILE: beetsplug/lastgenre/client.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # Copyright 2026, J0J0 Todos. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Last.fm API client for genre lookups.""" from __future__ import annotations import traceback from typing import TYPE_CHECKING, Any import pylast from beets import plugins if TYPE_CHECKING: from collections.abc import Callable from beets.logging import BeetsLogger GenreCache = dict[str, list[str]] """Cache mapping entity keys to their genre lists. Keys are formatted as 'entity.arg1-arg2-...' (e.g., 'album.artist-title'). Values are lists of lowercase genre strings.""" LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) PYLAST_EXCEPTIONS = ( pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError, ) class LastFmClient: """Client for fetching genres from Last.fm.""" def __init__(self, log: BeetsLogger, min_weight: int): """Initialize the client. The min_weight parameter filters tags by their minimum weight. """ self._log = log self._min_weight = min_weight self._genre_cache: GenreCache = {} def fetch_genre( self, lastfm_obj: pylast.Album | pylast.Artist | pylast.Track ) -> list[str]: """Return genres for a pylast entity. Returns an empty list if no suitable genres are found. """ return self._tags_for(lastfm_obj, self._min_weight) def _tags_for( self, obj: pylast.Album | pylast.Artist | pylast.Track, min_weight: int | None = None, ) -> list[str]: """Core genre identification routine. Given a pylast entity (album or track), return a list of tag names for that entity. Return an empty list if the entity is not found or another error occurs. If `min_weight` is specified, tags are filtered by weight. """ # Work around an inconsistency in pylast where # Album.get_top_tags() does not return TopItem instances. # https://github.com/pylast/pylast/issues/86 obj_to_query: Any = obj if isinstance(obj, pylast.Album): obj_to_query = super(pylast.Album, obj) try: res: Any = obj_to_query.get_top_tags() except PYLAST_EXCEPTIONS as exc: self._log.debug("last.fm error: {}", exc) return [] except Exception as exc: # Isolate bugs in pylast. self._log.debug("{}", traceback.format_exc()) self._log.error("error in pylast library: {}", exc) return [] # Filter by weight (optionally). if min_weight: res = [el for el in res if (int(el.weight or 0)) >= min_weight] # Get strings from tags. tags: list[str] = [el.item.get_name().lower() for el in res] return tags def _last_lookup( self, entity: str, method: Callable[..., Any], *args: str ) -> list[str]: """Get genres based on the named entity using the callable `method` whose arguments are given in the sequence `args`. The genre lookup is cached based on the entity name and the arguments. Before the lookup, each argument has the "-" Unicode character replaced with its rough ASCII equivalents in order to return better results from the Last.fm database. """ # Shortcut if we're missing metadata. if any(not s for s in args): return [] key = f"{entity}.{'-'.join(str(a) for a in args)}" if key not in self._genre_cache: args_replaced = [a.replace("\u2010", "-") for a in args] self._genre_cache[key] = self.fetch_genre(method(*args_replaced)) genre = self._genre_cache[key] self._log.extra_debug("last.fm (unfiltered) {} tags: {}", entity, genre) return genre def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]: """Return genres from Last.fm for the album by albumartist.""" return self._last_lookup( "album", LASTFM.get_album, albumartist, albumtitle ) def fetch_artist_genre(self, artist: str) -> list[str]: """Return genres from Last.fm for the artist.""" return self._last_lookup("artist", LASTFM.get_artist, artist) def fetch_track_genre(self, trackartist: str, tracktitle: str) -> list[str]: """Return genres from Last.fm for the track by artist.""" return self._last_lookup( "track", LASTFM.get_track, trackartist, tracktitle ) ================================================ FILE: beetsplug/lastgenre/genres-tree.yaml ================================================ - african: - african heavy metal - african hip hop - afrobeat - apala - benga - bikutsi - bongo flava - cape jazz - chimurenga - coupé-décalé - egyptian - fuji music - genge - highlife - hiplife - isicathamiya - jit - jùjú - kapuka - kizomba - kuduro - kwaito - kwela - makossa - maloya - marrabenta - mbalax - mbaqanga - mbube - morna - museve - palm-wine - raï - sakara - sega - seggae - semba - shangaan electro - soukous - taarab - zouglou - asian: - east asian: - anison - c-pop - cantopop - enka - hong kong english pop - j-pop - k-pop - kayōkyoku - korean pop - mandopop - onkyokei - taiwanese pop - fann at-tanbura - fijiri - khaliji - liwa - sawt - south and southeast asian: - baila - bhangra - bhojpuri - dangdut - filmi - indian pop - lavani - luk thung: - luk krung - manila sound - morlam - pinoy pop - pop sunda - ragini - thai pop - avant-garde: - experimental music - lo-fi - musique concrète - blues: - african blues - blues rock - blues shouter - british blues - canadian blues - chicago blues - classic female blues - contemporary r&b - country blues - delta blues - detroit blues - electric blues - gospel blues - hill country blues - hokum blues - jazz blues - jump blues - kansas city blues - louisiana blues - memphis blues - piano blues - piedmont blues - punk blues - soul blues - st. louis blues - swamp blues - texas blues - west coast blues - caribbean and latin american: - bachata - baithak gana - bolero - brazilian: - axé - bossa nova - brazilian rock - brega - choro - forró - frevo - funk carioca - lambada - maracatu - música popular brasileira - música sertaneja - pagode - samba - samba rock - tecnobrega - tropicalia - zouk-lambada - calypso - chutney - chutney soca - compas - folklore argentino - mambo - merengue - méringue - other latin: - chicha - criolla - cumbia - huayno - mariachi - ranchera - tejano - punta - punta rock - rasin - reggaeton - salsa - soca - son - timba - twoubadou - zouk - classical: - ballet - baroque: - baroque music - cantata - chamber music: - string quartet - classical music - concerto: - concerto grosso - contemporary classical - modern classical - opera - oratorio - orchestra: - orchestral - symphonic - symphony - organum - mass: - requiem - sacred music: - cantique - gregorian chant - sonata - comedy: - comedy music - comedy rock - humor - parody music - stand-up - kabarett - country: - alternative country: - cowpunk - americana - australian country music - bakersfield sound - bluegrass: - progressive bluegrass - reactionary bluegrass - blues country - cajun: - cajun fiddle tunes - christian country music - classic country - close harmony - country pop - country rap - country rock - country soul - cowboy/western music - dansband music - franco-country - gulf and western - hellbilly music - hokum - honky tonk - instrumental country - lubbock sound - nashville sound - neotraditional country - outlaw country - progressive country - psychobilly/punkabilly - red dirt - rockabilly - sertanejo - texas country - traditional country music - truck-driving country - western swing - zydeco - easy listening: - background music - beautiful music - elevator music - furniture music - lounge music - middle of the road - new-age music - electronic: - ambient: - ambient dub - ambient house - ambient techno - dark ambient - drone music - illbient - isolationism - lowercase - asian underground - breakbeat: - 4-beat - acid breaks - baltimore club - big beat - broken beat - florida breaks - nu skool breaks - chiptune: - bitpop - game boy music - nintendocore - video game music - yorkshire bleeps and bass - disco: - cosmic disco - disco polo - euro disco - italo disco - nu-disco - space disco - downtempo: - acid jazz - balearic beat - chill out - dub music - dubtronica - ethnic electronica - moombahton - nu jazz - trip hop - drum and bass: - darkcore - darkstep - drumfunk - drumstep - hardstep - intelligent drum and bass - jump-up - liquid funk - neurofunk - jungle: - darkside jungle - ragga jungle - oldschool jungle - raggacore - sambass - techstep - leftfield - halftime - electro: - crunk - electro backbeat - electro-grime - electropop - electroacoustic: - acousmatic music - computer music - electroacoustic improvisation - field recording - live coding - live electronics - soundscape composition - tape music - electronic rock: - alternative dance: - baggy - madchester - dance-punk - dance-rock - dark wave - electroclash - electronicore - electropunk - ethereal wave - indietronica - new rave - space rock - synthpop - synthpunk - electronica: - berlin school - chillwave - electronic art music - electronic dance music - folktronica - freestyle music - glitch - idm - laptronica - skweee - sound art - synthcore - eurodance: - bubblegum dance - italo dance - turbofolk - hardcore: - bouncy house - bouncy techno - breakbeat hardcore - breakcore - digital hardcore - doomcore - dubstyle - gabber - happy hardcore - hardstyle - jumpstyle - makina - speedcore - terrorcore - uk hardcore - hi-nrg: - eurobeat - hard nrg - new beat - house: - acid house - chicago house - deep house - diva house - dutch house - electro house - freestyle house - french house - funky house - ghetto house - hardbag - hip house - italo house - latin house - minimal house - progressive house - rave music - swing house - tech house - tribal house - uk hard house - us garage - vocal house - industrial: - aggrotech - coldwave - cybergrind - dark electro - death industrial - electro-industrial - electronic body music: - futurepop - industrial metal: - neue deutsche härte - industrial rock - noise: - japanoise - power electronics - power noise - witch house - juke: - footwork - post-disco: - boogie - dance-pop - progressive: - progressive house/trance: - disco house - dream house - space house - progressive breaks - progressive drum & bass - progressive techno - techno: - acid techno - detroit techno - dub techno - free tekno - ghettotech - minimal - nortec - schranz - techno-dnb - technopop - tecno brega - toytown techno - trance: - acid trance - classic trance - dream trance - goa trance: - dark psytrance - full on - psybreaks - psyprog - suomisaundi - hard trance - tech trance - uplifting trance: - orchestral uplifting - vocal trance - uk garage: - 2-step - 4x4 - bassline - breakstep - dubstep - funky - grime - speed garage - trap - folk: - american folk revival - anti-folk - british folk revival - celtic music - contemporary folk - filk music - freak folk - indie folk - industrial folk - neofolk - progressive folk - psychedelic folk - sung poetry - techno-folk - hip hop: - alternative hip hop - avant-garde hip hop - chap hop - christian hip hop - conscious hip hop - crunkcore - cumbia rap - east coast hip hop: - brick city club - hardcore hip hop - mafioso rap - new jersey hip hop - electro music - freestyle rap - g-funk - gangsta rap - glitch hop - golden age hip hop - hip hop soul - hip pop - hyphy - industrial hip hop - instrumental hip hop - jazz rap - low bap - lyrical hip hop - merenrap - midwest hip hop: - chicago hip hop - detroit hip hop - horrorcore - st. louis hip hop - twin cities hip hop - motswako - nerdcore - new jack swing - new school hip hop - old school hip hop - political hip hop - rap opera - rap rock: - rap metal - rapcore - songo-salsa - southern hip hop: - atlanta hip hop: - snap music - bounce music - houston hip hop: - chopped and screwed - miami bass - turntablism - underground hip hop - urban pasifika - west coast hip hop: - chicano rap - jerkin' - austrian hip hop - german hip hop - jazz: - asian american jazz - avant-garde jazz - bebop - boogie-woogie - brass band - british dance band - chamber jazz - continental jazz - cool jazz - crossover jazz - cubop - dixieland - ethno jazz - european free jazz - free funk - free improvisation - free jazz - gypsy jazz - hard bop - jazz fusion - jazz rock - jazz-funk - kansas city jazz - latin jazz - livetronica - m-base - mainstream jazz - modal jazz - neo-bop jazz - neo-swing - novelty ragtime - orchestral jazz - post-bop - punk jazz - ragtime - shibuya-kei - ska jazz - smooth jazz - soul jazz - straight-ahead jazz - stride jazz - swing - third stream - trad jazz - vocal jazz - west coast gypsy jazz - west coast jazz - kids music: - kinderlieder - pop: - adult contemporary - arab pop - baroque pop - bubblegum pop - christian pop - classical crossover - europop: - austropop - balkan pop - french pop - latin pop - laïkó - nederpop - russian pop - iranian pop - jangle pop - latin ballad - levenslied - louisiana swamp pop - mexican pop - motorpop - new romanticism - pop rap - popera - psychedelic pop - schlager - soft rock - sophisti-pop - space age pop - sunshine pop - surf pop - teen pop - traditional pop music - turkish pop - vispop - wonky pop - rhythm and blues: - funk: - deep funk - go-go - p-funk - soul: - blue-eyed soul - neo soul - northern soul - rock: - alternative rock: - britpop: - post-britpop - dream pop - grunge: - post-grunge - indie pop: - dunedin sound - twee pop - indie rock - noise pop - nu metal - post-punk revival - post-rock: - post-metal - sadcore - shoegaze - slowcore - art rock - beat music - chinese rock - christian rock - classic rock - dark cabaret - desert rock - experimental rock - folk rock - garage rock - glam rock - hard rock - heavy metal: - alternative metal: - funk metal - black metal: - viking metal - christian metal - death metal: - death/doom - goregrind - melodic death metal - technical death metal - doom metal: - epic doom metal - funeral doom - drone metal - epic metal - folk metal: - celtic metal - medieval metal - pagan metal - funk metal - glam metal - gothic metal - industrial metal: - industrial death metal - metalcore: - deathcore - mathcore: - djent - synthcore - neoclassical metal - post-metal - power metal: - progressive power metal - progressive metal - sludge metal - speed metal - stoner rock: - stoner metal - symphonic metal - thrash metal: - crossover thrash - groove metal - progressive thrash metal - teutonic thrash metal - traditional heavy metal - math rock - new wave: - world fusion - paisley underground - pop rock - post-punk: - gothic rock - no wave - noise rock - power pop - progressive rock: - canterbury scene - krautrock - new prog - rock in opposition - psychedelic rock: - acid rock - freakbeat - neo-psychedelia - raga rock - punk rock: - anarcho punk: - crust punk: - d-beat - art punk - christian punk - deathrock - deutschpunk - folk punk: - celtic punk - gypsy punk - garage punk - grindcore: - crustgrind - noisegrind - hardcore punk: - post-hardcore: - emo: - screamo - powerviolence - street punk - thrashcore - horror punk - oi! - pop punk - psychobilly - riot grrrl - ska punk: - ska-core - skate punk - rock and roll - southern rock - sufi rock - surf rock - visual kei: - nagoya kei - reggae: - roots reggae - reggae fusion - reggae en español: - spanish reggae - reggae 110 - reggae bultrón - romantic flow - lovers rock - raggamuffin: - ragga - dancehall - ska: - 2 tone - rocksteady - dub - soundtrack: - singer-songwriter: - cantautorato - cantautor - cantautora - chanson - canción de autor - nueva canción - world: - world dub - world fusion - worldbeat ================================================ FILE: beetsplug/lastgenre/genres.txt ================================================ 2 tone 2-step garage 4-beat 4x4 garage 8-bit acapella acid acid breaks acid house acid jazz acid rock acoustic music acousticana adult contemporary music african popular music african rumba afrobeat aleatoric music alternative country alternative dance alternative hip hop alternative metal alternative rock ambient ambient house ambient music americana anarcho punk anti-folk apala ape haters arab pop arabesque arabic pop argentine rock ars antiqua ars nova art punk art rock ashiq asian american jazz australian country music australian hip hop australian pub rock austropop avant-garde avant-garde jazz avant-garde metal avant-garde music axé bac-bal bachata baggy baila baile funk baisha xiyue baithak gana baião bajourou bakersfield sound bakou bakshy bal-musette balakadri balinese gamelan balkan pop ballad ballata ballet bamboo band bambuco banda bangsawan bantowbol barbershop music barndance baroque baroque music baroque pop bass music batcave batucada batuco batá-rumba beach music beat beatboxing beautiful music bebop beiguan bel canto bend-skin benga berlin school of electronic music bhajan bhangra bhangra-wine bhangragga bhangramuffin big band big band music big beat biguine bihu bikutsi biomusic bitcore bitpop black metal blackened death metal blue-eyed soul bluegrass blues blues ballad blues-rock boogie boogie woogie boogie-woogie bossa nova brass band brazilian funk brazilian jazz breakbeat breakbeat hardcore breakcore breton music brill building pop britfunk british blues british invasion britpop broken beat brown-eyed soul brukdown brutal death metal bubblegum dance bubblegum pop bulerias bumba-meu-boi bunraku burger-highlife burgundian school byzantine chant ca din tulnic ca pe lunca ca trù cabaret cadence cadence rampa cadence-lypso café-aman cai luong cajun music cakewalk calenda calentanos calgia calypso calypso jazz calypso-style baila campursari canatronic canción de autor candombe canon canrock cantata cantautorato cantautor cantautora cante chico cante jondo canterbury scene cantiga cantique cantiñas canto livre canto nuevo canto popular cantopop canzone napoletana cape jazz capoeira music caracoles carceleras cardas cardiowave carimbó cariso carnatic music carol cartageneras cassette culture casséy-co cavacha caveman caña celempungan cello rock celtic celtic fusion celtic metal celtic punk celtic reggae celtic rock cha-cha-cha chakacha chalga chamamé chamber jazz chamber music chamber pop champeta changuí chanson chant charanga charanga-vallenata charikawi chastushki chau van chemical breaks chicago blues chicago house chicago soul chicano rap chicha chicken scratch children's music chillout chillwave chimurenga chinese music chinese pop chinese rock chip music cho-kantrum chongak chopera chorinho choro chouval bwa chowtal christian alternative christian black metal christian electronic music christian hardcore christian hip hop christian industrial christian metal christian music christian punk christian r&b christian rock christian ska christmas carol christmas music chumba chut-kai-pang chutney chutney soca chutney-bhangra chutney-hip hop chutney-soca chylandyk chzalni chèo cigányzene classic classic country classic female blues classic rock classical classical music classical music era clicks n cuts close harmony club music cocobale coimbra fado coladeira colombianas combined rhythm comedy comedy rap comedy rock comic opera comparsa compas direct compas meringue concert overture concerto concerto grosso congo conjunto contemporary christian contemporary christian music contemporary classical contemporary r&b contonbley contradanza cool jazz corrido corsican polyphonic song cothoza mfana country country blues country gospel country music country pop country r&b country rock country-rap countrypolitan couple de sonneurs coupé-décalé cowpunk cretan music crossover jazz crossover music crossover thrash crossover thrash metal crunk crunk&b crunkcore crust punk csárdás cuarteto cuban rumba cuddlecore cueca cumbia cumbia villera cybergrind dabka dadra daina dalauna dance dance music dance-pop dance-punk dance-rock dancehall dangdut danger music dansband danza danzón dark ambient dark cabaret dark pop darkcore darkstep darkwave de ascultat la servici de codru de dragoste de jale de pahar death industrial death metal death rock death/doom deathcore deathgrind deathrock deep funk deep house deep soul degung delta blues dementia desert rock desi detroit blues detroit techno dub techno dhamar dhimotiká dhrupad dhun digital hardcore dirge dirty dutch dirty rap dirty rap/pornocore dirty south disco disco house disco polo disney disney hardcore disney pop diva house divine rock dixieland dixieland jazz djambadon djent dodompa doina dombola dondang sayang donegal fiddle tradition dongjing doo wop doom metal doomcore downtempo drag dream pop drone doom drone metal drone music dronology drum and bass dub dub house dubanguthu dubstep dubtronica dunedin sound dunun dutch jazz décima early music east coast blues east coast hip hop easy listening electric blues electric folk electro electro backbeat electro hop electro house electro punk electro-industrial electro-swing electroclash electrofunk electronic electronic art music electronic body music electronic dance electronic luk thung electronic music electronic rock electronica electropop elevator music emo emo pop emo rap emocore emotronic enka epic doom metal epic metal eremwu eu ethereal pop ethereal wave euro euro disco eurobeat eurodance europop eurotrance eurourban exotica experimental music experimental noise experimental pop experimental rock extreme metal ezengileer fado falak fandango farruca fife and drum blues filk film score filmi filmi-ghazal finger-style fjatpangarri flamenco flamenco rumba flower power foaie verde fofa folk hop folk metal folk music folk pop folk punk folk rock folktronica forró franco-country freak-folk freakbeat free improvisation free jazz free music freestyle freestyle house freetekno french pop frenchcore frevo fricote fuji fuji music fulia full on funaná funeral doom funk funk metal funk rock funkcore funky house furniture music fusion jazz g-funk gaana gabba gabber gagaku gaikyoku gaita galant gamad gambang kromong gamelan gamelan angklung gamelan bang gamelan bebonangan gamelan buh gamelan degung gamelan gede gamelan kebyar gamelan salendro gamelan selunding gamelan semar pegulingan gamewave gammeldans gandrung gangsta rap gar garage rock garrotin gavotte gelugpa chanting gender wayang gending german folk music gharbi gharnati ghazal ghazal-song ghetto house ghettotech girl group glam metal glam punk glam rock glitch gnawa go-go goa goa trance gong-chime music goombay goregrind goshu ondo gospel music gothic metal gothic rock granadinas grebo gregorian chant grime grindcore groove metal group sounds grunge grupera guaguanbo guajira guasca guitarra baiana guitarradas gumbe gunchei gunka guoyue gwo ka gwo ka moderne gypsy jazz gypsy punk gypsybilly gyu ke habanera hajnali hakka halling hambo hands up hapa haole happy hardcore haqibah hard hard bop hard house hard rock hard trance hardcore hip hop hardcore metal hardcore punk hardcore techno hardstyle harepa harmonica blues hasaposérviko heart attack heartland rock heavy beat heavy metal hesher hi-nrg highlands highlife highlife fusion hillybilly music hindustani classical music hip hop hip hop & rap hip hop soul hip house hiplife hiragasy hiva usu hong kong and cantonese pop hong kong english pop honky tonk honkyoku hora lunga hornpipe horror punk horrorcore horrorcore rap house house music hua'er huasteco huayno hula humor humppa hunguhungu hyangak hymn hyphy hát chau van hát chèo hát cãi luong hát tuồng ibiza music icaro idm igbo music ijexá ilahije illbient impressionist music improvisational incidental music indian pop indie folk indie music indie pop indie rock indietronica indo jazz indo rock indonesian pop indoyíftika industrial death metal industrial hip hop industrial metal industrial music industrial musical industrial rock instrumental rock intelligent dance music international latin inuit music iranian pop irish folk irish rebel music iscathamiya isicathamiya isikhwela jo island isolationist italo dance italo disco italo house itsmeños izvorna bosanska muzika j'ouvert j-fusion j-pop j-rock jaipongan jaliscienses jam band jam rock jamana kura jamrieng samai jangle pop japanese pop jarana jariang jarochos jawaiian jazz jazz blues jazz fusion jazz metal jazz rap jazz-funk jazz-rock jegog jenkka jesus music jibaro jig jig punk jing ping jingle jit jitterbug jive joged joged bumbung joik jonnycore joropo jota jtek jug band jujitsu juju juke joint blues jump blues jumpstyle jungle junkanoo juré jùjú k-pop kaba kabuki kachāshī kadans kagok kagyupa chanting kaiso kalamatianó kalattuut kalinda kamba pop kan ha diskan kansas city blues kantrum kantádhes kargyraa karma kaseko katajjaq kawachi ondo kayōkyoku ke-kwe kebyar kecak kecapi suling kertok khaleeji khap khelimaski djili khene khoomei khorovodi khplam wai khrung sai khyal kilapanda kinko kirtan kiwi rock kizomba klape klasik klezmer kliningan kléftiko kochare kolomyjka komagaku kompa konpa korean pop koumpaneia kpanlogo krakowiak krautrock kriti kroncong krump krzesany kuduro kulintang kulning kumina kun-borrk kundere kundiman kussundé kutumba wake kveding kvæði kwaito kwassa kwassa kwela käng kélé kĩkũyũ pop la la latin american latin jazz latin pop latin rap lavway laïko laïkó le leagan legényes lelio letkajenkka levenslied lhamo lieder light music light rock likanos liquid drum&bass liquid funk liquindi llanera llanto lo-fi lo-fi music loki djili long-song louisiana blues louisiana swamp pop lounge music lovers rock lowercase lubbock sound lucknavi thumri luhya omutibo luk grung lullaby lundu lundum m-base madchester madrigal mafioso rap maglaal magnificat mahori mainstream jazz makossa makossa-soukous malagueñas malawian jazz malhun maloya maluf maluka mambo manaschi mandarin pop manding swing mango mangue bit mangulina manikay manila sound manouche manzuma mapouka mapouka-serré marabi maracatu marga mariachi marimba marinera marrabenta martial industrial martinetes maskanda mass matamuerte math rock mathcore matt bello maxixe mazurka mbalax mbaqanga mbube mbumba medh medieval folk rock medieval metal medieval music meditation mejorana melhoun melhûn melodic black metal melodic death metal melodic hardcore melodic metalcore melodic music melodic trance memphis blues memphis rap memphis soul mento merengue merengue típico moderno merengue-bomba meringue merseybeat metal metalcore metallic hardcore mexican pop mexican rock mexican son meykhana mezwed miami bass microhouse middle of the road midwest hip hop milonga min'yo mineras mini compas mini-jazz minimal techno minimalist music minimalist trance minneapolis sound minstrel show minuet mirolóyia modal jazz modern classical modern classical music modern laika modern rock modinha mohabelo montuno monumental dance mor lam mor lam sing morna motorpop motown mozambique mpb mugam multicultural murga musette museve mushroom jazz music drama music hall musiqi-e assil musique concrète mutuashi muwashshah muzak méringue música campesina música criolla música de la interior música llanera música nordestina música popular brasileira música tropical nagauta nakasi nangma nanguan narcocorrido nardcore narodna muzika nasheed nashville sound nashville sound/countrypolitan national socialist black metal naturalismo nederpop neo soul neo-classical metal neo-medieval neo-prog neo-psychedelia neoclassical neoclassical metal neoclassical music neofolk neotraditional country nerdcore neue deutsche härte neue deutsche welle new age music new beat new instrumental new jack swing new orleans blues new orleans jazz new pop new prog new rave new romantic new school hip hop new taiwanese song new wave new wave of british heavy metal new wave of new wave new weird america new york blues new york house newgrass nganja nightcore nintendocore nisiótika no wave noh noise music noise pop noise rock nongak norae undong nordic folk dance music nordic folk music nortec norteño northern soul nota nu jazz nu metal nu soul nu skool breaks nueva canción nyatiti néo kýma obscuro oi! old school hip hop old-time oldies olonkho oltului ondo opera operatic pop oratorio orchestra orchestral organ trio organic ambient organum orgel oriental metal ottava rima outlaw country outsider music p-funk pagan metal pagan rock pagode paisley underground palm wine palm-wine pambiche panambih panchai baja panchavadyam pansori paranda parang parody parranda partido alto pasillo patriotic peace punk pelimanni music petenera peyote song philadelphia soul piano blues piano rock piedmont blues pimba pinoy pop pinoy rock pinpeat orchestra piphat piyyutim plainchant plena pleng phua cheewit pleng thai sakorn political hip hop polka polo polonaise pols polska pong lang pop pop folk pop music pop punk pop rap pop rock pop sunda pornocore porro post disco post-britpop post-disco post-grunge post-hardcore post-industrial post-metal post-minimalism post-punk post-rock post-romanticism pow-wow power electronics power metal power noise power pop powerviolence ppongtchak praise song program symphony progressive bluegrass progressive country progressive death metal progressive electronic progressive electronic music progressive folk progressive folk music progressive house progressive metal progressive power metal progressive rock progressive trance progressive thrash metal protopunk psych folk psychedelic music psychedelic pop psychedelic rock psychedelic trance psychobilly punk blues punk cabaret punk jazz punk rock punta punta rock qasidah qasidah modern qawwali quadrille quan ho queercore quiet storm rada raga raga rock ragga ragga jungle raggamuffin ragtime rai rake-and-scrape ramkbach ramvong ranchera rap rap metal rap rock rapcore rara rare groove rasiya rave raw rock raï rebetiko red dirt reel reggae reggae 110 reggae bultrón reggae en español reggae fusion reggae highlife reggaefusion reggaeton rekilaulu relax music religious rembetiko renaissance music requiem rhapsody rhyming spiritual rhythm & blues rhythm and blues ricercar riot grrrl rock rock and roll rock en español rock opera rockabilly rocksteady rococo romantic flow romantic period in music rondeaux ronggeng roots reggae roots rock roots rock reggae rumba russian pop rímur sabar sacred harp sacred music sadcore saibara sakara salegy salsa salsa erotica salsa romantica saltarello samba samba-canção samba-reggae samba-rock sambai sanjo sato kagura sawt saya scat schlager schottisch schranz scottish baroque music screamo scrumpy and western sea shanty sean nós second viennese school sega music seggae seis semba sephardic music serialism set dance sevdalinka sevillana shabab shabad shalako shan'ge shango shape note shibuya-kei shidaiqu shima uta shock rock shoegaze shoegazer shoka shomyo show tune sica siguiriyas silat sinawi situational ska ska punk skacore skald skate punk skiffle slack-key guitar slide slowcore sludge metal slängpolska smooth jazz soca soft rock son son montuno son-batá sonata songo songo-salsa sophisti-pop soukous soul soul blues soul jazz soul music southern gospel southern harmony southern hip hop southern metal southern rock southern soul space age pop space music space rock spectralism speed garage speed metal speedcore spirituals spouge sprechgesang square dance squee st. louis blues stand-up steelband stoner metal stoner rock straight edge strathspeys stride string string quartet sufi music suite sunshine pop suomirock super eurobeat surf ballad surf instrumental surf music surf pop surf rock swamp blues swamp pop swamp rock swing swing music swingbeat sygyt symphonic symphonic black metal symphonic metal symphonic poem symphonic rock symphony synthcore synthpop synthpunk t'ong guitar taarab tai tu taiwanese pop tala talempong tambu tamburitza tamil christian keerthanai tango tanguk tappa tarana tarantella taranto tech tech house tech trance technical death metal technical metal techno technoid technopop techstep techtonik teen pop tejano tejano music tekno tembang sunda teutonic thrash metal texas blues thai pop thillana thrash metal thrashcore thumri tibetan pop tiento timbila tin pan alley tinga tinku toeshey togaku trad jazz traditional bluegrass traditional heavy metal traditional pop music trallalero trance tribal house trikitixa trip hop trip rock tropicalia tropicalismo tropipop truck-driving country tumba turbo-folk turkish music turkish pop turntablism tuvan throat-singing twee pop twist two tone táncház uk garage uk pub rock unblack metal underground music uplifting uplifting trance urban cowboy urban folk urban jazz vallenato vaudeville venezuela verbunkos verismo viking metal villanella virelai vispop visual kei visual music vocal vocal house vocal jazz vocal music volksmusik waila waltz wangga warabe uta wassoulou weld were music west coast hip hop west coast jazz western western blues western swing witch house wizard rock women's music wong shadow wonky pop wood work song world fusion world fusion music world music worldbeat xhosa music xoomii yo-pop yodeling yukar yé-yé zajal zapin zarzuela zeibekiko zeuhl ziglibithy zouglou zouk zouk chouv zouklove zulu music zydeco ================================================ FILE: beetsplug/lastimport.py ================================================ # This file is part of beets. # Copyright 2016, Rafael Bodill https://github.com/rafi # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import pylast from pylast import TopItem, _extract, _number from beets import config, dbcore, plugins, ui from beets.dbcore import types API_URL = "https://ws.audioscrobbler.com/2.0/" class LastImportPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() config["lastfm"].add( { "user": "", "api_key": plugins.LASTFM_KEY, } ) config["lastfm"]["user"].redact = True config["lastfm"]["api_key"].redact = True self.config.add( { "per_page": 500, "retry_limit": 3, } ) self.item_types = { "lastfm_play_count": types.INTEGER, } def commands(self): cmd = ui.Subcommand("lastimport", help="import last.fm play-count") def func(lib, opts, args): import_lastfm(lib, self._log) cmd.func = func return [cmd] class CustomUser(pylast.User): """Custom user class derived from pylast.User, and overriding the _get_things method to return MBID and album. Also introduces new get_top_tracks_by_page method to allow access to more than one page of top tracks. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _get_things( self, method, thing, thing_type, params=None, cacheable=True ): """Returns a list of the most played thing_types by this thing, in a tuple with the total number of pages of results. Includes an MBID, if found. """ doc = self._request(f"{self.ws_prefix}.{method}", cacheable, params) toptracks_node = doc.getElementsByTagName("toptracks")[0] total_pages = int(toptracks_node.getAttribute("totalPages")) seq = [] for node in doc.getElementsByTagName(thing): title = _extract(node, "name") artist = _extract(node, "name", 1) mbid = _extract(node, "mbid") playcount = _number(_extract(node, "playcount")) thing = thing_type(artist, title, self.network) thing.mbid = mbid seq.append(TopItem(thing, playcount)) return seq, total_pages def get_top_tracks_by_page( self, period=pylast.PERIOD_OVERALL, limit=None, page=1, cacheable=True ): """Returns the top tracks played by a user, in a tuple with the total number of pages of results. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params["period"] = period params["page"] = page if limit: params["limit"] = limit return self._get_things( "getTopTracks", "track", pylast.Track, params, cacheable ) def import_lastfm(lib, log): user = config["lastfm"]["user"].as_str() per_page = config["lastimport"]["per_page"].get(int) if not user: raise ui.UserError("You must specify a user name for lastimport") log.info("Fetching last.fm library for @{}", user) page_total = 1 page_current = 0 found_total = 0 unknown_total = 0 retry_limit = config["lastimport"]["retry_limit"].get(int) # Iterate through a yet to be known page total count while page_current < page_total: log.info( "Querying page #{}{}...", page_current + 1, f"/{page_total}" if page_total > 1 else "", ) for retry in range(0, retry_limit): tracks, page_total = fetch_tracks(user, page_current + 1, per_page) if page_total < 1: # It means nothing to us! raise ui.UserError("Last.fm reported no data.") if tracks: found, unknown = process_tracks(lib, tracks, log) found_total += found unknown_total += unknown break else: log.error("ERROR: unable to read page #{}", page_current + 1) if retry < retry_limit: log.info( "Retrying page #{}... ({}/{} retry)", page_current + 1, retry + 1, retry_limit, ) else: log.error( "FAIL: unable to fetch page #{}, ", "tried {} times", page_current, retry + 1, ) page_current += 1 log.info("... done!") log.info("finished processing {} song pages", page_total) log.info("{} unknown play-counts", unknown_total) log.info("{} play-counts imported", found_total) def fetch_tracks(user, page, limit): """JSON format: [ { "mbid": "...", "artist": "...", "title": "...", "playcount": "..." } ] """ network = pylast.LastFMNetwork(api_key=config["lastfm"]["api_key"]) user_obj = CustomUser(user, network) results, total_pages = user_obj.get_top_tracks_by_page( limit=limit, page=page ) return [ { "mbid": track.item.mbid if track.item.mbid else "", "artist": {"name": track.item.artist.name}, "name": track.item.title, "playcount": track.weight, } for track in results ], total_pages def process_tracks(lib, tracks, log): total = len(tracks) total_found = 0 total_fails = 0 log.info("Received {} tracks in this page, processing...", total) for num in range(0, total): song = None trackid = tracks[num]["mbid"].strip() if tracks[num]["mbid"] else None artist = ( tracks[num]["artist"].get("name", "").strip() if tracks[num]["artist"].get("name", "") else None ) title = tracks[num]["name"].strip() if tracks[num]["name"] else None album = "" if "album" in tracks[num]: album = ( tracks[num]["album"].get("name", "").strip() if tracks[num]["album"] else None ) log.debug("query: {} - {} ({})", artist, title, album) # First try to query by musicbrainz's trackid if trackid: song = lib.items( dbcore.query.MatchQuery("mb_trackid", trackid) ).get() # If not, try just album/title if song is None: log.debug( "no album match, trying by album/title: {} - {}", album, title ) query = dbcore.AndQuery( [ dbcore.query.SubstringQuery("album", album), dbcore.query.SubstringQuery("title", title), ] ) song = lib.items(query).get() # If not, try just artist/title if song is None: log.debug("no album match, trying by artist/title") query = dbcore.AndQuery( [ dbcore.query.SubstringQuery("artist", artist), dbcore.query.SubstringQuery("title", title), ] ) song = lib.items(query).get() # Last resort, try just replacing to utf-8 quote if song is None: title = title.replace("'", "\u2019") log.debug("no title match, trying utf-8 single quote") query = dbcore.AndQuery( [ dbcore.query.SubstringQuery("artist", artist), dbcore.query.SubstringQuery("title", title), ] ) song = lib.items(query).get() if song is not None: count = int(song.get("lastfm_play_count", 0)) new_count = int(tracks[num]["playcount"]) log.debug( "match: {0.artist} - {0.title} ({0.album}) updating:" " lastfm_play_count {1} => {2}", song, count, new_count, ) song["lastfm_play_count"] = new_count song.store() total_found += 1 else: total_fails += 1 log.info(" - No match: {} - {} ({})", artist, title, album) if total_fails > 0: log.info( "Acquired {}/{} play-counts ({} unknown)", total_found, total, total_fails, ) return total_found, total_fails ================================================ FILE: beetsplug/limit.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds head/tail functionality to list/ls. 1. Implemented as `lslimit` command with `--head` and `--tail` options. This is the idiomatic way to use this plugin. 2. Implemented as query prefix `<` for head functionality only. This is the composable way to use the plugin (plays nicely with anything that uses the query language). """ from collections import deque from itertools import islice from beets.dbcore import FieldQuery from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ def lslimit(lib, opts, args): """Query command with head/tail.""" if (opts.head is not None) and (opts.tail is not None): raise ValueError("Only use one of --head and --tail") if (opts.head or opts.tail or 0) < 0: raise ValueError("Limit value must be non-negative") if opts.album: objs = lib.albums(args) else: objs = lib.items(args) if opts.head is not None: objs = islice(objs, opts.head) elif opts.tail is not None: objs = deque(objs, opts.tail) for obj in objs: print_(format(obj)) lslimit_cmd = Subcommand("lslimit", help="query with optional head or tail") lslimit_cmd.parser.add_option( "--head", action="store", type="int", default=None ) lslimit_cmd.parser.add_option( "--tail", action="store", type="int", default=None ) lslimit_cmd.parser.add_all_common_options() lslimit_cmd.func = lslimit class LimitPlugin(BeetsPlugin): """Query limit functionality via command and query prefix.""" def commands(self): """Expose `lslimit` subcommand.""" return [lslimit_cmd] def queries(self): class HeadQuery(FieldQuery): """This inner class pattern allows the query to track state.""" n = 0 N = None def __init__(self, *args, **kwargs) -> None: """Force the query to be slow so that 'value_match' is called.""" super().__init__(*args, **kwargs) self.fast = False @classmethod def value_match(cls, pattern, value): if cls.N is None: cls.N = int(pattern) if cls.N < 0: raise ValueError("Limit value must be non-negative") cls.n += 1 return cls.n <= cls.N return {"<": HeadQuery} ================================================ FILE: beetsplug/listenbrainz.py ================================================ """Adds Listenbrainz support to Beets.""" import datetime import requests from beets import config, ui from beets.plugins import BeetsPlugin from beetsplug.lastimport import process_tracks from ._utils.musicbrainz import MusicBrainzAPIMixin class ListenBrainzPlugin(MusicBrainzAPIMixin, BeetsPlugin): """A Beets plugin for interacting with ListenBrainz.""" ROOT = "http://api.listenbrainz.org/1/" def __init__(self): """Initialize the plugin.""" super().__init__() self.token = self.config["token"].get() self.username = self.config["username"].get() self.AUTH_HEADER = {"Authorization": f"Token {self.token}"} config["listenbrainz"]["token"].redact = True def commands(self): """Add beet UI commands to interact with ListenBrainz.""" lbupdate_cmd = ui.Subcommand( "lbimport", help="Import ListenBrainz history" ) def func(lib, opts, args): self._lbupdate(lib, self._log) lbupdate_cmd.func = func return [lbupdate_cmd] def _lbupdate(self, lib, log): """Obtain view count from Listenbrainz.""" found_total = 0 unknown_total = 0 ls = self.get_listens() tracks = self.get_tracks_from_listens(ls) log.info("Found {} listens", len(ls)) if tracks: found, unknown = process_tracks(lib, tracks, log) found_total += found unknown_total += unknown log.info("... done!") log.info("{} unknown play-counts", unknown_total) log.info("{} play-counts imported", found_total) def _make_request(self, url, params=None): """Makes a request to the ListenBrainz API.""" try: response = requests.get( url=url, headers=self.AUTH_HEADER, timeout=10, params=params, ) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: self._log.debug("Invalid Search Error: {}", e) return None def get_listens(self, min_ts=None, max_ts=None, count=None): """Gets the listen history of a given user. Args: username: User to get listen history of. min_ts: History before this timestamp will not be returned. DO NOT USE WITH max_ts. max_ts: History after this timestamp will not be returned. DO NOT USE WITH min_ts. count: How many listens to return. If not specified, uses a default from the server. Returns: A list of listen info dictionaries if there's an OK status. Raises: An HTTPError if there's a failure. A ValueError if the JSON in the response is invalid. An IndexError if the JSON is not structured as expected. """ url = f"{self.ROOT}/user/{self.username}/listens" params = { k: v for k, v in { "min_ts": min_ts, "max_ts": max_ts, "count": count, }.items() if v is not None } response = self._make_request(url, params) if response is not None: return response["payload"]["listens"] else: return None def get_tracks_from_listens(self, listens): """Returns a list of tracks from a list of listens.""" tracks = [] for track in listens: if track["track_metadata"].get("release_name") is None: continue mbid_mapping = track["track_metadata"].get("mbid_mapping", {}) mbid = None if mbid_mapping.get("recording_mbid") is None: # search for the track using title and release mbid = self.get_mb_recording_id(track) tracks.append( { "album": { "name": track["track_metadata"].get("release_name") }, "name": track["track_metadata"].get("track_name"), "artist": { "name": track["track_metadata"].get("artist_name") }, "mbid": mbid, "release_mbid": mbid_mapping.get("release_mbid"), "listened_at": track.get("listened_at"), } ) return tracks def get_mb_recording_id(self, track) -> str | None: """Returns the MusicBrainz recording ID for a track.""" results = self.mb_api.search( "recording", { "": track["track_metadata"].get("track_name"), "release": track["track_metadata"].get("release_name"), }, ) return next((r["id"] for r in results), None) def get_playlists_createdfor(self, username): """Returns a list of playlists created by a user.""" url = f"{self.ROOT}/user/{username}/playlists/createdfor" return self._make_request(url) def get_listenbrainz_playlists(self): resp = self.get_playlists_createdfor(self.username) playlists = resp.get("playlists") listenbrainz_playlists = [] for playlist in playlists: playlist_info = playlist.get("playlist") if playlist_info.get("creator") == "listenbrainz": title = playlist_info.get("title") self._log.debug("Playlist title: {}", title) playlist_type = ( "Exploration" if "Exploration" in title else "Jams" ) if "week of" in title: date_str = title.split("week of ")[1].split(" ")[0] date = datetime.datetime.strptime( date_str, "%Y-%m-%d" ).date() else: continue identifier = playlist_info.get("identifier") id = identifier.split("/")[-1] listenbrainz_playlists.append( {"type": playlist_type, "date": date, "identifier": id} ) listenbrainz_playlists = sorted( listenbrainz_playlists, key=lambda x: x["type"] ) listenbrainz_playlists = sorted( listenbrainz_playlists, key=lambda x: x["date"], reverse=True ) for playlist in listenbrainz_playlists: self._log.debug("Playlist: {0[type]} - {0[date]}", playlist) return listenbrainz_playlists def get_playlist(self, identifier): """Returns a playlist.""" url = f"{self.ROOT}/playlist/{identifier}" return self._make_request(url) def get_tracks_from_playlist(self, playlist): """This function returns a list of tracks in the playlist.""" tracks = [] for track in playlist.get("playlist").get("track"): identifier = track.get("identifier") if isinstance(identifier, list): identifier = identifier[0] tracks.append( { "artist": track.get("creator", "Unknown artist"), "identifier": identifier.split("/")[-1], "title": track.get("title"), } ) return self.get_track_info(tracks) def get_track_info(self, tracks): track_info = [] for track in tracks: identifier = track.get("identifier") recording = self.mb_api.get_recording( identifier, includes=["releases", "artist-credits"] ) title = recording.get("title") artist_credit = recording.get("artist-credit", []) if artist_credit: artist = artist_credit[0].get("artist", {}).get("name") else: artist = None releases = recording.get("releases", []) if releases: album = releases[0].get("title") date = releases[0].get("date") year = date.split("-")[0] if date else None else: album = None year = None track_info.append( { "identifier": identifier, "title": title, "artist": artist, "album": album, "year": year, } ) return track_info def get_weekly_playlist(self, playlist_type, most_recent=True): # Fetch all playlists playlists = self.get_listenbrainz_playlists() # Filter playlists by type filtered_playlists = [ p for p in playlists if p["type"] == playlist_type ] # Sort playlists by date in descending order sorted_playlists = sorted( filtered_playlists, key=lambda x: x["date"], reverse=True ) # Select the most recent or older playlist based on the most_recent flag selected_playlist = ( sorted_playlists[0] if most_recent else sorted_playlists[1] ) self._log.debug( f"Selected playlist: {selected_playlist['type']} " f"- {selected_playlist['date']}" ) # Fetch and return tracks from the selected playlist playlist = self.get_playlist(selected_playlist.get("identifier")) return self.get_tracks_from_playlist(playlist) def get_weekly_exploration(self): return self.get_weekly_playlist("Exploration", most_recent=True) def get_weekly_jams(self): return self.get_weekly_playlist("Jams", most_recent=True) def get_last_weekly_exploration(self): return self.get_weekly_playlist("Exploration", most_recent=False) def get_last_weekly_jams(self): return self.get_weekly_playlist("Jams", most_recent=False) ================================================ FILE: beetsplug/loadext.py ================================================ # This file is part of beets. # Copyright 2019, Jack Wilsdon <jack.wilsdon@gmail.com> # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Load SQLite extensions.""" import sqlite3 from beets.dbcore import Database from beets.plugins import BeetsPlugin class LoadExtPlugin(BeetsPlugin): def __init__(self): super().__init__() if not Database.supports_extensions: self._log.warning( "loadext is enabled but the current SQLite " "installation does not support extensions" ) return self.register_listener("library_opened", self.library_opened) def library_opened(self, lib): for v in self.config: ext = v.as_filename() self._log.debug("loading extension {}", ext) try: lib.load_extension(ext) except sqlite3.OperationalError as e: self._log.error("failed to load extension {}: {}", ext, e) ================================================ FILE: beetsplug/lyrics.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetches, embeds, and displays lyrics.""" from __future__ import annotations import itertools import math import re import textwrap from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import cached_property, partial, total_ordering from html import unescape from itertools import groupby from pathlib import Path from typing import TYPE_CHECKING, ClassVar, NamedTuple from urllib.parse import quote, quote_plus, urlencode, urlparse import requests from bs4 import BeautifulSoup from unidecode import unidecode from beets import plugins, ui from beets.autotag.distance import string_dist from beets.dbcore import types from beets.util.config import sanitize_choices from beets.util.lyrics import INSTRUMENTAL_LYRICS, Lyrics from ._utils.requests import HTTPNotFoundError, RequestHandler if TYPE_CHECKING: from collections.abc import Iterable, Iterator import confuse from beets.importer import ImportTask from beets.library import Item, Library from beets.logging import BeetsLogger as Logger from ._typing import ( GeniusAPI, GoogleCustomSearchAPI, JSONDict, LRCLibAPI, TranslatorAPI, ) class CaptchaError(requests.exceptions.HTTPError): def __init__(self, *args, **kwargs) -> None: super().__init__("Captcha is required", *args, **kwargs) class GeniusHTTPError(requests.exceptions.HTTPError): pass # Utilities. def search_pairs(item): """Yield a pairs of artists and titles to search for. The first item in the pair is the name of the artist, the second item is a list of song names. In addition to the artist and title obtained from the `item` the method tries to strip extra information like paranthesized suffixes and featured artists from the strings and add them as candidates. The artist sort name is added as a fallback candidate to help in cases where artist name includes special characters or is in a non-latin script. The method also tries to split multiple titles separated with `/`. """ def generate_alternatives(string, patterns): """Generate string alternatives by extracting first matching group for each given pattern. """ alternatives = [string] for pattern in patterns: match = re.search(pattern, string, re.IGNORECASE) if match: alternatives.append(match.group(1)) return alternatives title, artist, artist_sort = ( item.title.strip(), item.artist.strip(), item.artist_sort.strip(), ) if not title or not artist: return () patterns = [ # Remove any featuring artists from the artists name rf"(.*?) {plugins.feat_tokens()}" ] # Skip various artists artists = [] lower_artist = artist.lower() if "various" not in lower_artist: artists.extend(generate_alternatives(artist, patterns)) # Use the artist_sort as fallback only if it differs from artist to avoid # repeated remote requests with the same search terms artist_sort_lower = artist_sort.lower() if ( artist_sort and lower_artist != artist_sort_lower and "various" not in artist_sort_lower ): artists.append(artist_sort) patterns = [ # Remove a parenthesized suffix from a title string. Common # examples include (live), (remix), and (acoustic). r"(.+?)\s+[(].*[)]$", # Remove any featuring artists from the title rf"(.*?) {plugins.feat_tokens(for_artist=False)}", # Remove part of title after colon ':' for songs with subtitles r"(.+?)\s*:.*", ] titles = generate_alternatives(title, patterns) # Check for a dual song (e.g. Pink Floyd - Speak to Me / Breathe) # and each of them. multi_titles = [] for title in titles: multi_titles.append([title]) if " / " in title: multi_titles.append([x.strip() for x in title.split(" / ")]) return itertools.product(artists, multi_titles) def slug(text: str) -> str: """Make a URL-safe, human-readable version of the given text This will do the following: 1. decode unicode characters into ASCII 2. shift everything to lowercase 3. strip whitespace 4. replace other non-word characters with dashes 5. strip extra dashes """ return re.sub(r"\W+", "-", unidecode(text).lower().strip()).strip("-") class LyricsRequestHandler(RequestHandler): _log: Logger def status_to_error(self, code: int) -> type[requests.HTTPError] | None: if err := super().status_to_error(code): return err if 300 <= code < 400: return CaptchaError return None def debug(self, message: str, *args) -> None: """Log a debug message with the class name.""" self._log.debug(f"{self.__class__.__name__}: {message}", *args) def info(self, message: str, *args) -> None: """Log an info message with the class name.""" self._log.info(f"{self.__class__.__name__}: {message}", *args) def warn(self, message: str, *args) -> None: """Log warning with the class name.""" self._log.warning(f"{self.__class__.__name__}: {message}", *args) @staticmethod def format_url(url: str, params: JSONDict | None) -> str: if not params: return url return f"{url}?{urlencode(params)}" def get_text( self, url: str, params: JSONDict | None = None, **kwargs ) -> str: """Return text / HTML data from the given URL. Set the encoding to None to let requests handle it because some sites set it incorrectly. """ url = self.format_url(url, params) self.debug("Fetching HTML from {}", url) r = self.get(url, **kwargs) r.encoding = None return r.text def get_json(self, url: str, params: JSONDict | None = None, **kwargs): """Return JSON data from the given URL.""" url = self.format_url(url, params) self.debug("Fetching JSON from {}", url) return super().get_json(url, **kwargs) def post_json(self, url: str, params: JSONDict | None = None, **kwargs): """Send POST request and return JSON response.""" url = self.format_url(url, params) self.debug("Posting JSON to {}", url) return self.request("post", url, **kwargs).json() @contextmanager def handle_request(self) -> Iterator[None]: try: yield except requests.JSONDecodeError: self.warn("Could not decode response JSON data") except requests.RequestException as exc: self.warn("Request error: {}", exc) class BackendClass(type): @property def name(cls) -> str: """Return lowercase name of the backend class.""" return cls.__name__.lower() class Backend(LyricsRequestHandler, metaclass=BackendClass): config: confuse.Subview def __init__(self, config: confuse.Subview, log: Logger) -> None: self._log = log self.config = config def fetch( self, artist: str, title: str, album: str, length: int ) -> Lyrics | None: """Return lyrics for a song, or ``None`` when no match is found.""" raise NotImplementedError @dataclass @total_ordering class LRCLyrics: """Hold LRCLib candidate data and ranking helpers for matching.""" #: Percentage tolerance for max duration difference between lyrics and item. DURATION_DIFF_TOLERANCE = 0.05 target_duration: float id: int duration: float instrumental: bool plain: str synced: str | None def __le__(self, other: LRCLyrics) -> bool: """Compare two lyrics items by their score.""" return self.dist < other.dist @classmethod def verify_synced_lyrics( cls, duration: float, lyrics: str | None ) -> str | None: """Accept synced lyrics only when the final timestamp fits duration.""" if lyrics and ( m := Lyrics.LINE_PARTS_PAT.match(lyrics.splitlines()[-1]) ): ts, _ = m.groups() if ts: mm, ss = map(float, ts.strip("[]").split(":")) if 60 * mm + ss <= duration: return lyrics return None @classmethod def make( cls, candidate: LRCLibAPI.Item, target_duration: float ) -> LRCLyrics: """Build a scored candidate from LRCLib payload data.""" duration = candidate["duration"] or 0.0 return cls( target_duration, candidate["id"], duration, candidate["instrumental"], candidate["plainLyrics"], cls.verify_synced_lyrics( target_duration, candidate["syncedLyrics"] ), ) @cached_property def duration_dist(self) -> float: """Return the absolute difference between lyrics and target duration.""" return abs(self.duration - self.target_duration) @cached_property def is_valid(self) -> bool: """Return whether the lyrics item is valid. Lyrics duration must be within the tolerance defined by :attr:`DURATION_DIFF_TOLERANCE`. """ return ( self.duration_dist <= self.target_duration * self.DURATION_DIFF_TOLERANCE ) @cached_property def dist(self) -> tuple[bool, float]: """Distance/score of the given lyrics item. Return a tuple with the following values: 1. Absolute difference between lyrics and target duration 2. Boolean telling whether synced lyrics are available. Best lyrics match is the one that has the closest duration to ``target_duration`` and has synced lyrics available. """ return not self.synced, self.duration_dist def get_text(self, want_synced: bool) -> str: """Return the preferred text form for this candidate.""" if self.instrumental: return INSTRUMENTAL_LYRICS if want_synced and self.synced: return "\n".join(map(str.strip, self.synced.splitlines())) return self.plain class LRCLib(Backend): """Fetch lyrics from the LRCLib API.""" BASE_URL = "https://lrclib.net/api" GET_URL = f"{BASE_URL}/get" SEARCH_URL = f"{BASE_URL}/search" def fetch_candidates( self, artist: str, title: str, album: str, length: int ) -> Iterator[list[LRCLibAPI.Item]]: """Yield lyrics candidates for the given song data. I found that the ``/get`` endpoint sometimes returns inaccurate or unsynced lyrics, while ``search`` yields more suitable candidates. Therefore, we prioritize the latter and rank the results using our own algorithm. If the search does not give suitable lyrics, we fall back to the ``/get`` endpoint. Return an iterator over lists of candidates. """ base_params = {"artist_name": artist, "track_name": title} get_params = {**base_params, "duration": length} if album: get_params["album_name"] = album yield self.get_json(self.SEARCH_URL, params=base_params) with suppress(HTTPNotFoundError): yield [self.get_json(self.GET_URL, params=get_params)] @classmethod def pick_best_match(cls, lyrics: Iterable[LRCLyrics]) -> LRCLyrics | None: """Return best matching lyrics item from the given list.""" return min((li for li in lyrics if li.is_valid), default=None) def fetch( self, artist: str, title: str, album: str, length: int ) -> Lyrics | None: """Fetch lyrics text for the given song data.""" evaluate_item = partial(LRCLyrics.make, target_duration=length) for group in self.fetch_candidates(artist, title, album, length): candidates = [evaluate_item(item) for item in group] if item := self.pick_best_match(candidates): lyrics = item.get_text(self.config["synced"].get(bool)) return Lyrics( lyrics, self.__class__.name, f"{self.GET_URL}/{item.id}" ) return None class MusiXmatch(Backend): URL_TEMPLATE = "https://www.musixmatch.com/lyrics/{}/{}" REPLACEMENTS: ClassVar[dict[str, str]] = { r"\s+": "-", "<": "Less_Than", ">": "Greater_Than", "#": "Number_", r"[\[\{]": "(", r"[\]\}]": ")", } @classmethod def encode(cls, text: str) -> str: for old, new in cls.REPLACEMENTS.items(): text = re.sub(old, new, text) return quote(unidecode(text)) @classmethod def build_url(cls, *args: str) -> str: return cls.URL_TEMPLATE.format(*map(cls.encode, args)) def fetch(self, artist: str, title: str, *_) -> Lyrics | None: url = self.build_url(artist, title) html = self.get_text(url) if "We detected that your IP is blocked" in html: self.warn("Failed: Blocked IP address") return None html_parts = html.split('<p class="mxm-lyrics__content') # Sometimes lyrics come in 2 or more parts lyrics_parts = [] for html_part in html_parts: lyrics_parts.append(re.sub(r"^[^>]+>|</p>.*", "", html_part)) lyrics = "\n".join(lyrics_parts) lyrics = lyrics.strip(',"').replace("\\n", "\n") # another odd case: sometimes only that string remains, for # missing songs. this seems to happen after being blocked # above, when filling in the CAPTCHA. if "Instant lyrics for all your music." in lyrics: return None # sometimes there are non-existent lyrics with some content if "Lyrics | Musixmatch" in lyrics: return None return Lyrics(lyrics, self.__class__.name, url) class Html: collapse_space = partial(re.compile(r"(^| ) +", re.M).sub, r"\1") expand_br = partial(re.compile(r"\s*<br[^>]*>\s*", re.I).sub, "\n") #: two newlines between paragraphs on the same line (musica, letras.mus.br) merge_blocks = partial(re.compile(r"(?<!>)</p><p[^>]*>").sub, "\n\n") #: a single new line between paragraphs on separate lines #: (paroles.net, sweetslyrics.com, lacoccinelle.net) merge_lines = partial(re.compile(r"</p>\s+<p[^>]*>(?!___)").sub, "\n") #: remove empty divs (lacoccinelle.net) remove_empty_tags = partial( re.compile(r"(<(div|span)[^>]*>\s*</\2>)").sub, "" ) #: remove Google Ads tags (musica.com) remove_aside = partial(re.compile("<aside .+?</aside>").sub, "") #: remove adslot-Content_1 div from the lyrics text (paroles.net) remove_adslot = partial( re.compile(r"\n</div>[^\n]+-- Content_\d+ --.*?\n<div>", re.S).sub, "\n", ) #: remove text formatting (azlyrics.com, lacocinelle.net) remove_formatting = partial( re.compile(r" *</?(i|em|pre|strong)[^>]*>").sub, "" ) @classmethod def normalize_space(cls, text: str) -> str: text = unescape(text).replace("\r", "").replace("\xa0", " ") return cls.collapse_space(cls.expand_br(text)) @classmethod def remove_ads(cls, text: str) -> str: return cls.remove_adslot(cls.remove_aside(text)) @classmethod def merge_paragraphs(cls, text: str) -> str: return cls.merge_blocks(cls.merge_lines(cls.remove_empty_tags(text))) class SoupMixin: @classmethod def pre_process_html(cls, html: str) -> str: """Pre-process the HTML content before scraping.""" return Html.normalize_space(html) @classmethod def get_soup(cls, html: str) -> BeautifulSoup: return BeautifulSoup(cls.pre_process_html(html), "html.parser") class SearchResult(NamedTuple): artist: str title: str url: str @property def source(self) -> str: return urlparse(self.url).netloc class SearchBackend(SoupMixin, Backend): @cached_property def dist_thresh(self) -> float: return self.config["dist_thresh"].get(float) def check_match( self, target_artist: str, target_title: str, result: SearchResult ) -> bool: """Check if the given search result is a 'good enough' match.""" max_dist = max( string_dist(target_artist, result.artist), string_dist(target_title, result.title), ) if (max_dist := round(max_dist, 2)) <= self.dist_thresh: return True if math.isclose(max_dist, self.dist_thresh, abs_tol=0.4): # log out the candidate that did not make it but was close. # This may show a matching candidate with some noise in the name self.debug( "({0.artist}, {0.title}) does not match ({1}, {2}) but dist" " was close: {3:.2f}", result, target_artist, target_title, max_dist, ) return False def search(self, artist: str, title: str) -> Iterable[SearchResult]: """Search for the given query and yield search results.""" raise NotImplementedError def get_results(self, artist: str, title: str) -> Iterable[SearchResult]: check_match = partial(self.check_match, artist, title) for candidate in self.search(artist, title): if check_match(candidate): yield candidate def fetch(self, artist: str, title: str, *_) -> Lyrics | None: """Fetch lyrics for the given artist and title.""" for result in self.get_results(artist, title): if (html := self.get_text(result.url)) and ( lyrics := self.scrape(html) ): return Lyrics(lyrics, self.__class__.name, result.url) return None @classmethod def scrape(cls, html: str) -> str | None: """Scrape the lyrics from the given HTML.""" raise NotImplementedError class Genius(SearchBackend): """Fetch lyrics from Genius via genius-api. Because genius doesn't allow accessing lyrics via the api, we first query the api for a url matching our artist & title, then scrape the HTML text for the JSON data containing the lyrics. """ SEARCH_URL = "https://api.genius.com/search" LYRICS_IN_JSON_RE = re.compile(r'(?<=.\\"html\\":\\").*?(?=(?<!\\)\\")') remove_backslash = partial(re.compile(r"\\(?=[^\\])").sub, "") @cached_property def headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.config['genius_api_key']}"} def get_json(self, *args, **kwargs) -> GeniusAPI.Search: response: GeniusAPI.Response = super().get_json(*args, **kwargs) if "response" in response: return response # type: ignore[return-value] meta = response["meta"] raise GeniusHTTPError(f"{meta['message']} Status: {meta['status']}") def search(self, artist: str, title: str) -> Iterable[SearchResult]: search_data = self.get_json( self.SEARCH_URL, params={"q": f"{artist} {title}"}, headers=self.headers, ) for r in (hit["result"] for hit in search_data["response"]["hits"]): yield SearchResult(r["artist_names"], r["title"], r["url"]) @classmethod def scrape(cls, html: str) -> str | None: if m := cls.LYRICS_IN_JSON_RE.search(html): html_text = cls.remove_backslash(m[0]).replace(r"\n", "\n") return cls.get_soup(html_text).get_text().strip() return None class Tekstowo(SearchBackend): """Fetch lyrics from Tekstowo.pl.""" BASE_URL = "https://www.tekstowo.pl" SEARCH_URL = f"{BASE_URL}/szukaj,{{}}.html" def build_url(self, artist, title): artistitle = f"{artist.title()} {title.title()}" return self.SEARCH_URL.format(quote_plus(unidecode(artistitle))) def search(self, artist: str, title: str) -> Iterable[SearchResult]: if html := self.get_text(self.build_url(title, artist)): soup = self.get_soup(html) for tag in soup.select("div[class=flex-group] > a[title*=' - ']"): artist, title = str(tag["title"]).split(" - ", 1) yield SearchResult( artist, title, f"{self.BASE_URL}{tag['href']}" ) return None @classmethod def scrape(cls, html: str) -> str | None: soup = cls.get_soup(html) if lyrics_div := soup.select_one("div.song-text > div.inner-text"): return lyrics_div.get_text() return None class Google(SearchBackend): """Fetch lyrics from Google search results.""" SEARCH_URL = "https://www.googleapis.com/customsearch/v1" #: Exclude some letras.mus.br pages which do not contain lyrics. EXCLUDE_PAGES: ClassVar[list[str]] = [ "significado.html", "traduccion.html", "traducao.html", "significados.html", ] #: Regular expression to match noise in the URL title. URL_TITLE_NOISE_RE = re.compile( r""" \b ( paroles(\ et\ traduction|\ de\ chanson)? | letras?(\ de)? | liedtexte | dainų\ žodžiai | original\ song\ full\ text\. | official | 20[12]\d\ version | (absolute\ |az)?lyrics(\ complete)? | www\S+ | \S+\.(com|net|mus\.br) ) ([^\w.]|$) """, re.IGNORECASE | re.VERBOSE, ) #: Split cleaned up URL title into artist and title parts. URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +|, ") SOURCE_DIST_FACTOR: ClassVar[dict[str, float]] = { "www.azlyrics.com": 0.5, "www.songlyrics.com": 0.6, } ignored_domains: ClassVar[set[str]] = set() @classmethod def pre_process_html(cls, html: str) -> str: """Pre-process the HTML content before scraping.""" html = Html.remove_ads(super().pre_process_html(html)) return Html.remove_formatting(Html.merge_paragraphs(html)) def get_text(self, *args, **kwargs) -> str: """Handle an error so that we can continue with the next URL.""" kwargs.setdefault("allow_redirects", False) with self.handle_request(): try: return super().get_text(*args, **kwargs) except CaptchaError: self.ignored_domains.add(urlparse(args[0]).netloc) raise @staticmethod def get_part_dist(artist: str, title: str, part: str) -> float: """Return the distance between the given part and the artist and title. A number between -1 and 1 is returned, where -1 means the part is closer to the artist and 1 means it is closer to the title. """ return string_dist(artist, part) - string_dist(title, part) @classmethod def make_search_result( cls, artist: str, title: str, item: GoogleCustomSearchAPI.Item ) -> SearchResult: """Parse artist and title from the URL title and return a search result.""" url_title = ( # get full title from metatags if available item.get("pagemap", {}).get("metatags", [{}])[0].get("og:title") # default to the dispolay title or item["title"] ) clean_title = cls.URL_TITLE_NOISE_RE.sub("", url_title).strip(" .-|") # split it into parts which may be part of the artist or the title # `dict.fromkeys` removes duplicates keeping the order parts = list(dict.fromkeys(cls.URL_TITLE_PARTS_RE.split(clean_title))) if len(parts) == 1: part = parts[0] if m := re.search(rf"(?i)\W*({re.escape(title)})\W*", part): # artist and title may not have a separator result_title = m[1] result_artist = part.replace(m[0], "") else: # assume that this is the title result_artist, result_title = "", parts[0] else: # sort parts by their similarity to the artist result_artist = min(parts, key=lambda p: string_dist(artist, p)) result_title = min(parts, key=lambda p: string_dist(title, p)) return SearchResult(result_artist, result_title, item["link"]) def search(self, artist: str, title: str) -> Iterable[SearchResult]: params = { "key": self.config["google_API_key"].as_str(), "cx": self.config["google_engine_ID"].as_str(), "q": f"{artist} {title}", "siteSearch": "www.musixmatch.com", "siteSearchFilter": "e", "excludeTerms": ", ".join(self.EXCLUDE_PAGES), } data: GoogleCustomSearchAPI.Response = self.get_json( self.SEARCH_URL, params=params ) for item in data.get("items", []): yield self.make_search_result(artist, title, item) def get_results(self, *args) -> Iterable[SearchResult]: """Try results from preferred sources first.""" for result in sorted( super().get_results(*args), key=lambda r: self.SOURCE_DIST_FACTOR.get(r.source, 1), ): if result.source not in self.ignored_domains: yield result @classmethod def scrape(cls, html: str) -> str | None: # Get the longest text element (if any). if strings := sorted(cls.get_soup(html).stripped_strings, key=len): return strings[-1] return None @dataclass class Translator(LyricsRequestHandler): """Translate lyrics text while preserving existing structured metadata.""" TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate" SEPARATOR = " | " _log: Logger api_key: str to_language: str from_languages: list[str] @classmethod def from_config( cls, log: Logger, api_key: str, to_language: str, from_languages: list[str] | None = None, ) -> Translator: """Construct a translator with normalized language configuration.""" return cls( log, api_key, to_language.upper(), [x.upper() for x in from_languages or []], ) def get_translations(self, texts: Iterable[str]) -> list[str]: """Return translations for the given texts. To reduce the translation 'cost', we translate unique texts, and then map the translations back to the original texts. """ unique_texts = list(dict.fromkeys(texts)) text = self.SEPARATOR.join(unique_texts) data: list[TranslatorAPI.Response] = self.post_json( self.TRANSLATE_URL, headers={"Ocp-Apim-Subscription-Key": self.api_key}, json=[{"text": text}], params={"api-version": "3.0", "to": self.to_language}, ) translated_text = data[0]["translations"][0]["text"] translations = translated_text.split(self.SEPARATOR) trans_by_text = dict(zip(unique_texts, translations)) return [trans_by_text.get(t, "") for t in texts] def translate(self, lyrics: Lyrics, old_lyrics: Lyrics) -> Lyrics: """Translate the given lyrics to the target language. Check old lyrics for existing translations and return them if their original text matches the new lyrics. This is to avoid translating the same lyrics multiple times. If the lyrics are already in the target language or not in any of of the source languages (if configured), they are returned as is. """ if ( lyrics.original_text ) == old_lyrics.original_text and old_lyrics.translated: self.info("🔵 Translations already exist") return old_lyrics if (lyrics_language := lyrics.language) == self.to_language: self.info( "🔵 Lyrics are already in the target language {.to_language}", self, ) elif ( from_lang_config := self.from_languages ) and lyrics_language not in from_lang_config: self.info( "🔵 Configuration {} does not permit translating from {}", from_lang_config, lyrics_language, ) else: with self.handle_request(): lyrics.translations = self.get_translations(lyrics.text_lines) lyrics.translation_language = self.to_language self.info("🟢 Translated lyrics to {.to_language}", self) return lyrics @dataclass class RestFiles: # The content for the base index.rst generated in ReST mode. REST_INDEX_TEMPLATE = textwrap.dedent(""" Lyrics ====== * :ref:`Song index <genindex>` * :ref:`search` Artist index: .. toctree:: :maxdepth: 1 :glob: artists/* """).strip() # The content for the base conf.py generated. REST_CONF_TEMPLATE = textwrap.dedent(""" master_doc = "index" project = "Lyrics" copyright = "none" author = "Various Authors" latex_documents = [ (master_doc, "Lyrics.tex", project, author, "manual"), ] epub_exclude_files = ["search.html"] epub_tocdepth = 1 epub_tocdup = False """).strip() directory: Path @cached_property def artists_dir(self) -> Path: dir = self.directory / "artists" dir.mkdir(parents=True, exist_ok=True) return dir def write_indexes(self) -> None: """Write conf.py and index.rst files necessary for Sphinx We write minimal configurations that are necessary for Sphinx to operate. We do not overwrite existing files so that customizations are respected.""" index_file = self.directory / "index.rst" if not index_file.exists(): index_file.write_text(self.REST_INDEX_TEMPLATE) conf_file = self.directory / "conf.py" if not conf_file.exists(): conf_file.write_text(self.REST_CONF_TEMPLATE) def write_artist(self, artist: str, items: Iterable[Item]) -> None: parts = [ f"{artist}\n{'=' * len(artist)}", ".. contents::\n :local:", ] for album, items in groupby(items, key=lambda i: i.album): parts.append(f"{album}\n{'-' * len(album)}") parts.extend( part for i in items if (title := f":index:`{i.title.strip()}`") for part in ( f"{title}\n{'~' * len(title)}", textwrap.indent(i.lyrics, "| "), ) ) file = self.artists_dir / f"{slug(artist)}.rst" file.write_text("\n\n".join(parts).strip()) def write(self, items: list[Item]) -> None: self.directory.mkdir(exist_ok=True, parents=True) self.write_indexes() items.sort(key=lambda i: i.albumartist) for artist, artist_items in groupby(items, key=lambda i: i.albumartist): self.write_artist(artist.strip(), artist_items) d = self.directory text = f""" ReST files generated. to build, use one of: sphinx-build -b html {d} {d / "html"} sphinx-build -b epub {d} {d / "epub"} sphinx-build -b latex {d} {d / "latex"} && make -C {d / "latex"} all-pdf """ ui.print_(textwrap.dedent(text)) BACKEND_BY_NAME = { b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch] } class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "lyrics_url": types.STRING, "lyrics_backend": types.STRING, "lyrics_language": types.STRING, "lyrics_translation_language": types.STRING, } @cached_property def backends(self) -> list[Backend]: user_sources = self.config["sources"].as_str_seq() chosen = sanitize_choices(user_sources, BACKEND_BY_NAME) if "google" in chosen and not self.config["google_API_key"].get(): self.warn("Disabling Google source: no API key configured.") chosen.remove("google") return [BACKEND_BY_NAME[c](self.config, self._log) for c in chosen] @cached_property def translator(self) -> Translator | None: config = self.config["translate"] if config["api_key"].get() and config["to_language"].get(): return Translator.from_config(self._log, **config.flatten()) return None def __init__(self): super().__init__() self.config.add( { "auto": True, "translate": { "api_key": None, "from_languages": [], "to_language": None, }, "dist_thresh": 0.11, "google_API_key": None, "google_engine_ID": "009217259823014548361:lndtuqkycfu", "genius_api_key": ( "Ryq93pUGm8bM6eUWwD_M3NOFFDAtp2yEE7W" "76V-uFL5jks5dNvcGCdarqFjDhP9c" ), "fallback": None, "force": False, "local": False, "print": False, "synced": False, # Musixmatch and Tekstowo are disabled by default as they # currently block requests with the beets user agent. "sources": [ n for n in BACKEND_BY_NAME if n not in {"musixmatch", "tekstowo"} ], } ) self.config["translate"]["api_key"].redact = True self.config["google_API_key"].redact = True self.config["google_engine_ID"].redact = True self.config["genius_api_key"].redact = True if self.config["auto"]: self.import_stages = [self.imported] def commands(self): cmd = ui.Subcommand("lyrics", help="fetch song lyrics") cmd.parser.add_option( "-p", "--print", action="store_true", default=self.config["print"].get(), help="print lyrics to console", ) cmd.parser.add_option( "-r", "--write-rest", dest="rest_directory", action="store", default=None, metavar="dir", help="write lyrics to given directory as ReST files", ) cmd.parser.add_option( "-f", "--force", action="store_true", default=self.config["force"].get(), help="always re-download lyrics", ) cmd.parser.add_option( "-l", "--local", action="store_true", default=self.config["local"].get(), help="do not fetch missing lyrics", ) def func(lib: Library, opts, args) -> None: # The "write to files" option corresponds to the # import_write config value. self.config.set(vars(opts)) items = list(lib.items(args)) for item in items: self.add_item_lyrics(item, ui.should_write()) if item.lyrics and opts.print: ui.print_(item.lyrics) if opts.rest_directory and ( items := [i for i in items if i.lyrics] ): RestFiles(Path(opts.rest_directory)).write(items) cmd.func = func return [cmd] def imported(self, _, task: ImportTask) -> None: """Import hook for fetching lyrics automatically.""" for item in task.imported_items(): self.add_item_lyrics(item, False) def find_lyrics(self, item: Item) -> Lyrics | None: """Return the first lyrics match from the configured source search.""" album, length = item.album, round(item.length) matches = ( self.get_lyrics(a, t, album, length) for a, titles in search_pairs(item) for t in titles ) return next(filter(None, matches), None) def add_item_lyrics(self, item: Item, write: bool) -> None: """Fetch and store lyrics for a single item. If ``write``, then the lyrics will also be written to the file itself. """ if self.config["local"]: return if not self.config["force"] and item.lyrics: self.info("🔵 Lyrics already present: {}", item) return existing_lyrics = Lyrics.from_item(item) if new_lyrics := self.find_lyrics(item): self.info("🟢 Found lyrics: {}", item) if translator := self.translator: new_lyrics = translator.translate(new_lyrics, existing_lyrics) synced_mode = self.config["synced"].get(bool) if synced_mode and existing_lyrics.synced and not new_lyrics.synced: self.info( "🔴 Not updating synced lyrics with non-synced ones: {}", item, ) return for key in ("backend", "url", "language", "translation_language"): item_key = f"lyrics_{key}" if value := getattr(new_lyrics, key): item[item_key] = value elif item_key in item: del item[item_key] lyrics_text = new_lyrics.full_text else: self.info("🔴 Lyrics not found: {}", item) lyrics_text = self.config["fallback"].get() if lyrics_text not in {None, item.lyrics}: item.lyrics = lyrics_text item.store() if write: item.try_write() def get_lyrics(self, artist: str, title: str, *args) -> Lyrics | None: """Get first found lyrics, trying each source in turn.""" self.info("Fetching lyrics for {} - {}", artist, title) for backend in self.backends: with backend.handle_request(): if lyrics_info := backend.fetch(artist, title, *args): return lyrics_info return None ================================================ FILE: beetsplug/mbcollection.py ================================================ # This file is part of beets. # Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red> # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import re from dataclasses import dataclass, field from functools import cached_property from typing import TYPE_CHECKING, ClassVar from requests.auth import HTTPDigestAuth from beets import __version__, config, ui from beets.plugins import BeetsPlugin from beets.ui import Subcommand from ._utils.musicbrainz import MusicBrainzAPI if TYPE_CHECKING: from collections.abc import Iterable, Iterator from requests import Response from beets.importer import ImportSession, ImportTask from beets.library import Album, Library from ._typing import JSONDict UUID_PAT = re.compile(r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") @dataclass class MusicBrainzUserAPI(MusicBrainzAPI): """MusicBrainz API client with user authentication. In order to retrieve private user collections and modify them, we need to authenticate the requests with the user's MusicBrainz credentials. See documentation for authentication details: https://musicbrainz.org/doc/MusicBrainz_API#Authentication Note that the documentation misleadingly states HTTP 'basic' authentication, and I had to reverse-engineer musicbrainzngs to discover that it actually uses HTTP 'digest' authentication. """ auth: HTTPDigestAuth = field(init=False) def __post_init__(self) -> None: super().__post_init__() config["musicbrainz"]["pass"].redact = True self.auth = HTTPDigestAuth( config["musicbrainz"]["user"].as_str(), config["musicbrainz"]["pass"].as_str(), ) def request(self, *args, **kwargs) -> Response: """Authenticate and include required client param in all requests.""" kwargs.setdefault("params", {}) kwargs["params"]["client"] = f"beets-{__version__}" kwargs["auth"] = self.auth return super().request(*args, **kwargs) def browse_collections(self) -> list[JSONDict]: """Get all collections for the authenticated user.""" return self._browse("collection") @dataclass class MBCollection: """Representation of a user's MusicBrainz collection. Provides convenient, chunked operations for retrieving releases and updating the collection via the MusicBrainz web API. Fetch and submission limits are controlled by class-level constants to avoid oversized requests. """ SUBMISSION_CHUNK_SIZE: ClassVar[int] = 200 FETCH_CHUNK_SIZE: ClassVar[int] = 100 data: JSONDict mb_api: MusicBrainzUserAPI @property def id(self) -> str: """Unique identifier assigned to the collection by MusicBrainz.""" return self.data["id"] @property def release_count(self) -> int: """Total number of releases recorded in the collection.""" return self.data["release-count"] @property def releases_url(self) -> str: """Complete API endpoint URL for listing releases in this collection.""" return f"{self.mb_api.api_root}/collection/{self.id}/releases" @property def releases(self) -> list[JSONDict]: """Retrieve all releases in the collection, fetched in successive pages. The fetch is performed in chunks and returns a flattened sequence of release records. """ offsets = list(range(0, self.release_count, self.FETCH_CHUNK_SIZE)) return [r for offset in offsets for r in self.get_releases(offset)] def get_releases(self, offset: int) -> list[JSONDict]: """Fetch a single page of releases beginning at a given position.""" return self.mb_api.get_json( self.releases_url, params={"limit": self.FETCH_CHUNK_SIZE, "offset": offset}, )["releases"] @classmethod def get_id_chunks(cls, id_list: list[str]) -> Iterator[list[str]]: """Yield successive sublists of identifiers sized for safe submission. Splits a long sequence of identifiers into batches that respect the service's submission limits to avoid oversized requests. """ for i in range(0, len(id_list), cls.SUBMISSION_CHUNK_SIZE): yield id_list[i : i + cls.SUBMISSION_CHUNK_SIZE] def add_releases(self, releases: list[str]) -> None: """Add releases to the collection in batches.""" for chunk in self.get_id_chunks(releases): # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.put(f"{self.releases_url}/{'%3B'.join(chunk)}") def remove_releases(self, releases: list[str]) -> None: """Remove releases from the collection in chunks.""" for chunk in self.get_id_chunks(releases): # Need to escape semicolons: https://github.com/psf/requests/issues/6990 self.mb_api.delete(f"{self.releases_url}/{'%3B'.join(chunk)}") def submit_albums(collection: MBCollection, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. """ collection.add_releases(release_ids) class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "auto": False, "collection": "", "remove": False, } ) if self.config["auto"]: self.import_stages = [self.imported] @cached_property def mb_api(self) -> MusicBrainzUserAPI: return MusicBrainzUserAPI() @cached_property def collection(self) -> MBCollection: if not (collections := self.mb_api.browse_collections()): raise ui.UserError("no collections exist for user") # Get all release collection IDs, avoiding event collections if not ( collection_by_id := { c["id"]: c for c in collections if c["entity-type"] == "release" } ): raise ui.UserError("No release collection found.") # Check that the collection exists so we can present a nice error if collection_id := self.config["collection"].as_str(): if not (collection := collection_by_id.get(collection_id)): raise ui.UserError(f"invalid collection ID: {collection_id}") else: # No specified collection. Just return the first collection ID collection = next(iter(collection_by_id.values())) return MBCollection(collection, self.mb_api) def commands(self): mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection") mbupdate.parser.add_option( "-r", "--remove", action="store_true", default=None, dest="remove", help="Remove albums not in beets library", ) mbupdate.func = self.update_collection return [mbupdate] def update_collection(self, lib: Library, opts, args) -> None: self.config.set_args(opts) remove_missing = self.config["remove"].get(bool) self.update_album_list(lib, lib.albums(), remove_missing) def imported(self, session: ImportSession, task: ImportTask) -> None: """Add each imported album to the collection.""" if task.is_album: self.update_album_list( session.lib, [task.album], remove_missing=False ) def update_album_list( self, lib: Library, albums: Iterable[Album], remove_missing: bool ) -> None: """Update the MusicBrainz collection from a list of Beets albums""" collection = self.collection # Get a list of all the album IDs. album_ids = [id_ for a in albums if UUID_PAT.match(id_ := a.mb_albumid)] # Submit to MusicBrainz. self._log.info("Updating MusicBrainz collection {}...", collection.id) collection.add_releases(album_ids) if remove_missing: lib_ids = {x.mb_albumid for x in lib.albums()} albums_in_collection = {r["id"] for r in collection.releases} collection.remove_releases(list(albums_in_collection - lib_ids)) self._log.info("...MusicBrainz collection updated.") ================================================ FILE: beetsplug/mbpseudo.py ================================================ # This file is part of beets. # Copyright 2025, Alexis Sarda-Espinosa. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds pseudo-releases from MusicBrainz as candidates during import.""" from __future__ import annotations import itertools from copy import deepcopy from typing import TYPE_CHECKING, Any import mediafile from typing_extensions import override from beets import config from beets.autotag.distance import distance from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id from beetsplug.musicbrainz import ( MusicBrainzPlugin, _merge_pseudo_and_actual_album, _preferred_alias, ) if TYPE_CHECKING: from collections.abc import Iterable, Sequence from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.library import Item from beetsplug._typing import JSONDict _STATUS_PSEUDO = "Pseudo-Release" class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "scripts": [], "custom_tags_only": False, "album_custom_tags": { "album_transl": "album", "album_artist_transl": "artist", }, "track_custom_tags": { "title_transl": "title", "artist_transl": "artist", }, } ) self._scripts = self.config["scripts"].as_str_seq() self._log.debug("Desired scripts: {0}", self._scripts) album_custom_tags = self.config["album_custom_tags"].get().keys() track_custom_tags = self.config["track_custom_tags"].get().keys() self._log.debug( "Custom tags for albums and tracks: {0} + {1}", album_custom_tags, track_custom_tags, ) for custom_tag in album_custom_tags | track_custom_tags: if not isinstance(custom_tag, str): continue media_field = mediafile.MediaField( mediafile.MP3DescStorageStyle(custom_tag), mediafile.MP4StorageStyle( f"----:com.apple.iTunes:{custom_tag}" ), mediafile.StorageStyle(custom_tag), mediafile.ASFStorageStyle(custom_tag), ) try: self.add_media_field(custom_tag, media_field) except ValueError: # ignore errors due to duplicates pass self.register_listener("pluginload", self._on_plugins_loaded) self.register_listener("album_matched", self._adjust_final_album_match) # noinspection PyMethodMayBeStatic def _on_plugins_loaded(self): for plugin in find_plugins(): if isinstance(plugin, MusicBrainzPlugin) and not isinstance( plugin, MusicBrainzPseudoReleasePlugin ): raise RuntimeError( "The musicbrainz plugin should not be enabled together with" " the mbpseudo plugin" ) @override def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: if len(self._scripts) == 0: yield from super().candidates(items, artist, album, va_likely) else: for album_info in super().candidates( items, artist, album, va_likely ): if isinstance(album_info, PseudoAlbumInfo): self._log.debug( "Using {0} release for distance calculations for album {1}", album_info.determine_best_ref(items), album_info.album_id, ) yield album_info # first yield pseudo to give it priority yield album_info.get_official_release() else: yield album_info @override def album_info(self, release: JSONDict) -> AlbumInfo: official_release = super().album_info(release) if release.get("status") == _STATUS_PSEUDO: return official_release if (ids := self._intercept_mb_release(release)) and ( album_id := self._extract_id(ids[0]) ): raw_pseudo_release = self.mb_api.get_release(album_id) pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): self._replace_artist_with_alias( raw_pseudo_release, pseudo_release ) self._add_custom_tags(official_release, pseudo_release) return official_release else: return PseudoAlbumInfo( pseudo_release=_merge_pseudo_and_actual_album( pseudo_release, official_release ), official_release=official_release, ) else: return official_release def _intercept_mb_release(self, data: JSONDict) -> list[str]: album_id = data["id"] if "id" in data else None if self._has_desired_script(data) or not isinstance(album_id, str): return [] return [ pr_id for rel in data.get("release-relations", []) if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) is not None ] def _has_desired_script(self, release: JSONDict) -> bool: if len(self._scripts) == 0: return False elif script := release.get("text-representation", {}).get("script"): return script in self._scripts else: return False def _wanted_pseudo_release_id( self, album_id: str, relation: JSONDict, ) -> str | None: if ( len(self._scripts) == 0 or relation.get("type", "") != "transl-tracklisting" or relation.get("direction", "") != "forward" or "release" not in relation ): return None release = relation["release"] if "id" in release and self._has_desired_script(release): self._log.debug( "Adding pseudo-release {0} for main release {1}", release["id"], album_id, ) return release["id"] else: return None def _replace_artist_with_alias( self, raw_pseudo_release: JSONDict, pseudo_release: AlbumInfo, ): """Use the pseudo-release's language to search for artist alias if the user hasn't configured import languages.""" if len(config["import"]["languages"].as_str_seq()) > 0: return lang = raw_pseudo_release.get("text-representation", {}).get("language") artist_credits = raw_pseudo_release.get("release-group", {}).get( "artist-credit", [] ) aliases = [ artist_credit.get("artist", {}).get("aliases", []) for artist_credit in artist_credits ] if lang and len(lang) >= 2 and len(aliases) > 0: locale = lang[0:2] aliases_flattened = list(itertools.chain.from_iterable(aliases)) self._log.debug( "Using locale '{0}' to search aliases {1}", locale, aliases_flattened, ) if alias_dict := _preferred_alias(aliases_flattened, [locale]): if alias := alias_dict.get("name"): self._log.debug("Got alias '{0}'", alias) pseudo_release.artist = alias for track in pseudo_release.tracks: track.artist = alias def _add_custom_tags( self, official_release: AlbumInfo, pseudo_release: AlbumInfo, ): for tag_key, pseudo_key in ( self.config["album_custom_tags"].get().items() ): official_release[tag_key] = pseudo_release[pseudo_key] track_custom_tags = self.config["track_custom_tags"].get().items() for track, pseudo_track in zip( official_release.tracks, pseudo_release.tracks ): for tag_key, pseudo_key in track_custom_tags: track[tag_key] = pseudo_track[pseudo_key] def _adjust_final_album_match(self, match: AlbumMatch): album_info = match.info if isinstance(album_info, PseudoAlbumInfo): self._log.debug( "Switching {0} to pseudo-release source for final proposal", album_info.album_id, ) album_info.use_pseudo_as_ref() new_pairs, *_ = assign_items(match.items, album_info.tracks) album_info.mapping = dict(new_pairs) if album_info.data_source == self.data_source: album_info.data_source = "MusicBrainz" @override def _extract_id(self, url: str) -> str | None: return extract_release_id("MusicBrainz", url) class PseudoAlbumInfo(AlbumInfo): """This is a not-so-ugly hack. We want the pseudo-release to result in a distance that is lower or equal to that of the official release, otherwise it won't qualify as a good candidate. However, if the input is in a script that's different from the pseudo-release (and we want to translate/transliterate it in the library), it will receive unwanted penalties. This class is essentially a view of the ``AlbumInfo`` of both official and pseudo-releases, where it's possible to change the details that are exposed to other parts of the auto-tagger, enabling a "fair" distance calculation based on the current input's script but still preferring the translation/transliteration in the final proposal. """ def __init__( self, pseudo_release: AlbumInfo, official_release: AlbumInfo, **kwargs, ): super().__init__(pseudo_release.tracks, **kwargs) self.__dict__["_pseudo_source"] = True self.__dict__["_official_release"] = official_release for k, v in pseudo_release.items(): if k not in kwargs: self[k] = v def get_official_release(self) -> AlbumInfo: return self.__dict__["_official_release"] def determine_best_ref(self, items: Sequence[Item]) -> str: self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) self.use_official_as_ref() official_dist = self._compute_distance(items) if official_dist < pseudo_dist: self.use_official_as_ref() return "official" else: self.use_pseudo_as_ref() return "pseudo" def _compute_distance(self, items: Sequence[Item]) -> Distance: mapping, _, _ = assign_items(items, self.tracks) return distance(items, self, mapping) def use_pseudo_as_ref(self): self.__dict__["_pseudo_source"] = True def use_official_as_ref(self): self.__dict__["_pseudo_source"] = False def __getattr__(self, attr: str) -> Any: # ensure we don't duplicate an official release's id, always return pseudo's if self.__dict__["_pseudo_source"] or attr == "album_id": return super().__getattr__(attr) else: return self.__dict__["_official_release"].__getattr__(attr) def __deepcopy__(self, memo): cls = self.__class__ result = cls.__new__(cls) memo[id(self)] = result result.__dict__.update(self.__dict__) for k, v in self.items(): result[k] = deepcopy(v, memo) return result ================================================ FILE: beetsplug/mbsubmit.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Aid in submitting information to MusicBrainz. This plugin allows the user to print track information in a format that is parseable by the MusicBrainz track parser [1]. Programmatic submitting is not implemented by MusicBrainz yet. [1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings """ import subprocess from beets import ui from beets.autotag import Recommendation from beets.plugins import BeetsPlugin from beets.util import PromptChoice, displayable_path from beetsplug.info import print_data class MBSubmitPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "format": "$track. $title - $artist ($length)", "threshold": "medium", "picard_path": "picard", } ) # Validate and store threshold. self.threshold = self.config["threshold"].as_choice( { "none": Recommendation.none, "low": Recommendation.low, "medium": Recommendation.medium, "strong": Recommendation.strong, } ) self.register_listener( "before_choose_candidate", self.before_choose_candidate_event ) def before_choose_candidate_event(self, session, task): if task.rec <= self.threshold: return [ PromptChoice("p", "Print tracks", self.print_tracks), PromptChoice("o", "Open files with Picard", self.picard), ] def picard(self, session, task): paths = [] for p in task.paths: paths.append(displayable_path(p)) try: picard_path = self.config["picard_path"].as_str() subprocess.Popen([picard_path, *paths]) self._log.info("launched picard from\n{}", picard_path) except OSError as exc: self._log.error("Could not open picard, got error:\n{}", exc) def print_tracks(self, session, task): for i in sorted(task.items, key=lambda i: i.track): print_data(None, i, self.config["format"].as_str()) def commands(self): """Add beet UI commands for mbsubmit.""" mbsubmit_cmd = ui.Subcommand( "mbsubmit", help="Submit Tracks to MusicBrainz" ) def func(lib, opts, args): items = lib.items(args) self._mbsubmit(items) mbsubmit_cmd.func = func return [mbsubmit_cmd] def _mbsubmit(self, items): """Print track information to be submitted to MusicBrainz.""" for i in sorted(items, key=lambda i: i.track): print_data(None, i, self.config["format"].as_str()) ================================================ FILE: beetsplug/mbsync.py ================================================ # This file is part of beets. # Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronise library metadata with metadata source backends.""" from collections import defaultdict from beets import autotag, library, metadata_plugins, ui, util from beets.plugins import BeetsPlugin, apply_item_changes class MBSyncPlugin(BeetsPlugin): def __init__(self): super().__init__() def commands(self): cmd = ui.Subcommand("mbsync", help="update metadata from musicbrainz") cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show all changes but do nothing", ) cmd.parser.add_option( "-m", "--move", action="store_true", dest="move", help="move files in the library directory", ) cmd.parser.add_option( "-M", "--nomove", action="store_false", dest="move", help="don't move files in library", ) cmd.parser.add_option( "-W", "--nowrite", action="store_false", default=None, dest="write", help="don't write updated metadata to files", ) cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the mbsync function.""" move = ui.should_move(opts.move) pretend = opts.pretend write = ui.should_write(opts.write) self.singletons(lib, args, move, pretend, write) self.albums(lib, args, move, pretend, write) def singletons(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for items matched by query. """ for item in lib.items([*query, "singleton:true"]): if not (track_id := item.mb_trackid): self._log.info( "Skipping singleton with no mb_trackid: {}", item ) continue if not ( track_info := metadata_plugins.track_for_id( track_id, item.get("data_source", "MusicBrainz") ) ): self._log.info( "Recording ID not found: {} for track {}", track_id, item ) continue # Apply. with lib.transaction(): autotag.apply_item_metadata(item, track_info) apply_item_changes(lib, item, move, pretend, write) def albums(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for albums matched by query and their items. """ # Process matching albums. for album in lib.albums(query): if not (album_id := album.mb_albumid): self._log.info("Skipping album with no mb_albumid: {}", album) continue data_source = album.get("data_source") or album.items()[0].get( "data_source", "MusicBrainz" ) if not ( album_info := metadata_plugins.album_for_id( album_id, data_source ) ): self._log.info( "Release ID {} not found for album {}", album_id, album ) continue # Map release track and recording MBIDs to their information. # Recordings can appear multiple times on a release, so each MBID # maps to a list of TrackInfo objects. releasetrack_index = {} track_index = defaultdict(list) for track_info in album_info.tracks: releasetrack_index[track_info.release_track_id] = track_info track_index[track_info.track_id].append(track_info) # Construct a track mapping according to MBIDs (release track MBIDs # first, if available, and recording MBIDs otherwise). This should # work for albums that have missing or extra tracks. item_info_pairs = [] items = list(album.items()) for item in items: if ( item.mb_releasetrackid and item.mb_releasetrackid in releasetrack_index ): item_info_pairs.append( (item, releasetrack_index[item.mb_releasetrackid]) ) else: candidates = track_index[item.mb_trackid] if len(candidates) == 1: item_info_pairs.append((item, candidates[0])) else: # If there are multiple copies of a recording, they are # disambiguated using their disc and track number. for c in candidates: if ( c.medium_index == item.track and c.medium == item.disc ): item_info_pairs.append((item, c)) break # Apply. self._log.debug("applying changes to {}", album) with lib.transaction(): autotag.apply_metadata(album_info, item_info_pairs) changed = False # Find any changed item to apply changes to album. any_changed_item = items[0] for item in items: item_changed = ui.show_model_changes(item) changed |= item_changed if item_changed: any_changed_item = item apply_item_changes(lib, item, move, pretend, write) if not changed: # No change to any item. continue if not pretend: # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = any_changed_item[key] album.store() # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): self._log.debug("moving album {}", album) album.move() ================================================ FILE: beetsplug/metasync/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from music player libraries""" from __future__ import annotations from abc import ABCMeta, abstractmethod from importlib import import_module from typing import TYPE_CHECKING, ClassVar from confuse import ConfigValueError from beets import ui from beets.plugins import BeetsPlugin if TYPE_CHECKING: from beets.dbcore import types METASYNC_MODULE = "beetsplug.metasync" # Dictionary to map the MODULE and the CLASS NAME of meta sources SOURCES = { "amarok": "Amarok", "itunes": "Itunes", } class MetaSource(metaclass=ABCMeta): item_types: ClassVar[dict[str, types.Type]] def __init__(self, config, log): self.config = config self._log = log @abstractmethod def sync_from_source(self, item): pass def load_meta_sources(): """Returns a dictionary of all the MetaSources E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true """ meta_sources = {} for module_path, class_name in SOURCES.items(): module = import_module(f"{METASYNC_MODULE}.{module_path}") meta_sources[class_name.lower()] = getattr(module, class_name) return meta_sources META_SOURCES = load_meta_sources() def load_item_types(): """Returns a dictionary containing the item_types of all the MetaSources""" item_types = {} for meta_source in META_SOURCES.values(): item_types.update(meta_source.item_types) return item_types class MetaSyncPlugin(BeetsPlugin): item_types = load_item_types() def __init__(self): super().__init__() def commands(self): cmd = ui.Subcommand( "metasync", help="update metadata from music player libraries" ) cmd.parser.add_option( "-p", "--pretend", action="store_true", help="show all changes but do nothing", ) cmd.parser.add_option( "-s", "--source", default=[], action="append", dest="sources", help="comma-separated list of sources to sync", ) cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the metasync function.""" pretend = opts.pretend sources = [] for source in opts.sources: sources.extend(source.split(",")) sources = sources or self.config["source"].as_str_seq() meta_source_instances = {} items = lib.items(args) # Avoid needlessly instantiating meta sources (can be expensive) if not items: self._log.info("No items found matching query") return # Instantiate the meta sources for player in sources: try: cls = META_SOURCES[player] except KeyError: self._log.error("Unknown metadata source '{}'", player) try: meta_source_instances[player] = cls(self.config, self._log) except (ImportError, ConfigValueError) as e: self._log.error( "Failed to instantiate metadata source {!r}: {}", player, e ) # Avoid needlessly iterating over items if not meta_source_instances: self._log.error("No valid metadata sources found") return # Sync the items with all of the meta sources for item in items: for meta_source in meta_source_instances.values(): meta_source.sync_from_source(item) changed = ui.show_model_changes(item) if changed and not pretend: item.store() ================================================ FILE: beetsplug/metasync/amarok.py ================================================ # This file is part of beets. # Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from amarok's library via dbus""" from datetime import datetime from os.path import basename from time import mktime from typing import ClassVar from xml.sax.saxutils import quoteattr from beets.dbcore import types from beets.util import displayable_path from beetsplug.metasync import MetaSource def import_dbus(): try: return __import__("dbus") except ImportError: return None dbus = import_dbus() class Amarok(MetaSource): item_types: ClassVar[dict[str, types.Type]] = { "amarok_rating": types.INTEGER, "amarok_score": types.FLOAT, "amarok_uid": types.STRING, "amarok_playcount": types.INTEGER, "amarok_firstplayed": types.DATE, "amarok_lastplayed": types.DATE, } query_xml = """ <query version="1.0"> <filters> <and><include field="filename" value={} /></and> </filters> </query>""" def __init__(self, config, log): super().__init__(config, log) if not dbus: raise ImportError("failed to import dbus") self.collection = dbus.SessionBus().get_object( "org.kde.amarok", "/Collection" ) def sync_from_source(self, item): path = displayable_path(item.path) # amarok unfortunately doesn't allow searching for the full path, only # for the patch relative to the mount point. But the full path is part # of the result set. So query for the filename and then try to match # the correct item from the results we get back results = self.collection.Query( self.query_xml.format(quoteattr(basename(path))) ) for result in results: if result["xesam:url"] != path: continue item.amarok_rating = result["xesam:userRating"] item.amarok_score = result["xesam:autoRating"] item.amarok_playcount = result["xesam:useCount"] item.amarok_uid = result["xesam:id"].replace( "amarok-sqltrackuid://", "" ) if result["xesam:firstUsed"][0][0] != 0: # These dates are stored as timestamps in amarok's db, but # exposed over dbus as fixed integers in the current timezone. first_played = datetime( result["xesam:firstUsed"][0][0], result["xesam:firstUsed"][0][1], result["xesam:firstUsed"][0][2], result["xesam:firstUsed"][1][0], result["xesam:firstUsed"][1][1], result["xesam:firstUsed"][1][2], ) if result["xesam:lastUsed"][0][0] != 0: last_played = datetime( result["xesam:lastUsed"][0][0], result["xesam:lastUsed"][0][1], result["xesam:lastUsed"][0][2], result["xesam:lastUsed"][1][0], result["xesam:lastUsed"][1][1], result["xesam:lastUsed"][1][2], ) else: last_played = first_played item.amarok_firstplayed = mktime(first_played.timetuple()) item.amarok_lastplayed = mktime(last_played.timetuple()) ================================================ FILE: beetsplug/metasync/itunes.py ================================================ # This file is part of beets. # Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from iTunes's library""" import os import plistlib import shutil import tempfile from contextlib import contextmanager from time import mktime from typing import ClassVar from urllib.parse import unquote, urlparse from confuse import ConfigValueError from beets import util from beets.dbcore import types from beets.util import bytestring_path, syspath from beetsplug.metasync import MetaSource @contextmanager def create_temporary_copy(path): temp_dir = bytestring_path(tempfile.mkdtemp()) temp_path = os.path.join(temp_dir, b"temp_itunes_lib") shutil.copyfile(syspath(path), syspath(temp_path)) try: yield temp_path finally: shutil.rmtree(syspath(temp_dir)) def _norm_itunes_path(path): # Itunes prepends the location with 'file://' on posix systems, # and with 'file://localhost/' on Windows systems. # The actual path to the file is always saved as posix form # E.g., 'file://Users/Music/bar' or 'file://localhost/G:/Music/bar' # The entire path will also be capitalized (e.g., '/Music/Alt-J') # Note that this means the path will always have a leading separator, # which is unwanted in the case of Windows systems. # E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar' return util.bytestring_path( os.path.normpath(unquote(urlparse(path).path)).lstrip("\\") ).lower() class Itunes(MetaSource): item_types: ClassVar[dict[str, types.Type]] = { "itunes_rating": types.INTEGER, # 0..100 scale "itunes_playcount": types.INTEGER, "itunes_skipcount": types.INTEGER, "itunes_lastplayed": types.DATE, "itunes_lastskipped": types.DATE, "itunes_dateadded": types.DATE, } def __init__(self, config, log): super().__init__(config, log) config.add({"itunes": {"library": "~/Music/iTunes/iTunes Library.xml"}}) # Load the iTunes library, which has to be the .xml one (not the .itl) library_path = config["itunes"]["library"].as_filename() try: self._log.debug("loading iTunes library from {}", library_path) with create_temporary_copy(library_path) as library_copy: with open(library_copy, "rb") as library_copy_f: raw_library = plistlib.load(library_copy_f) except OSError as e: raise ConfigValueError(f"invalid iTunes library: {e.strerror}") except Exception: # It's likely the user configured their '.itl' library (<> xml) if os.path.splitext(library_path)[1].lower() != ".xml": hint = ( ": please ensure that the configured path" " points to the .XML library" ) else: hint = "" raise ConfigValueError(f"invalid iTunes library{hint}") # Make the iTunes library queryable using the path self.collection = { _norm_itunes_path(track["Location"]): track for track in raw_library["Tracks"].values() if "Location" in track } def sync_from_source(self, item): result = self.collection.get(util.bytestring_path(item.path).lower()) if not result: self._log.warning("no iTunes match found for {}", item) return item.itunes_rating = result.get("Rating") item.itunes_playcount = result.get("Play Count") item.itunes_skipcount = result.get("Skip Count") if result.get("Play Date UTC"): item.itunes_lastplayed = mktime( result.get("Play Date UTC").timetuple() ) if result.get("Skip Date"): item.itunes_lastskipped = mktime( result.get("Skip Date").timetuple() ) if result.get("Date Added"): item.itunes_dateadded = mktime(result.get("Date Added").timetuple()) ================================================ FILE: beetsplug/missing.py ================================================ # This file is part of beets. # Copyright 2016, Pedro Silva. # Copyright 2017, Quentin Young. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """List missing tracks.""" from __future__ import annotations from collections import defaultdict from typing import TYPE_CHECKING, ClassVar import requests from beets import config, metadata_plugins from beets.dbcore import types from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ from ._utils.musicbrainz import MusicBrainzAPIMixin if TYPE_CHECKING: from collections.abc import Iterator from beets.library import Album, Library # Valid MusicBrainz release types for filtering release groups VALID_RELEASE_TYPES = [ "nat", "album", "single", "ep", "broadcast", "other", "compilation", "soundtrack", "spokenword", "interview", "audiobook", "live", "remix", "dj-mix", "mixtape/street", ] MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$" def _missing_count(album): """Return number of missing items in `album`.""" return (album.albumtotal or 0) - len(album.items()) def _item(track_info, album_info, album_id): """Build and return `item` from `track_info` and `album info` objects. `item` is missing what fields cannot be obtained from MusicBrainz alone (encoder, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, original_year, original_month, original_day, length, bitrate, format, samplerate, bitdepth, channels, mtime.) """ t = track_info a = album_info return Item( **{ "album_id": album_id, "album": a.album, "albumartist": a.artist, "albumartist_credit": a.artist_credit, "albumartist_sort": a.artist_sort, "albumdisambig": a.albumdisambig, "albumstatus": a.albumstatus, "albumtype": a.albumtype, "artist": t.artist, "artist_credit": t.artist_credit, "artist_sort": t.artist_sort, "asin": a.asin, "catalognum": a.catalognum, "comp": a.va, "country": a.country, "day": a.day, "disc": t.medium, "disctitle": t.disctitle, "disctotal": a.mediums, "label": a.label, "language": a.language, "length": t.length, "mb_albumid": a.album_id, "mb_artistid": t.artist_id, "mb_releasegroupid": a.releasegroup_id, "mb_trackid": t.track_id, "media": t.media, "month": a.month, "script": a.script, "title": t.title, "track": t.index, "tracktotal": len(a.tracks), "year": a.year, } ) class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """List missing tracks""" album_types: ClassVar[dict[str, types.Type]] = { "missing": types.INTEGER, } def __init__(self): super().__init__() self.config.add( { "count": False, "total": False, "album": False, "release_types": ["album"], } ) self.album_template_fields["missing"] = _missing_count self._command = Subcommand("missing", help=__doc__, aliases=["miss"]) self._command.parser.add_option( "-c", "--count", dest="count", action="store_true", help="count missing tracks per album", ) self._command.parser.add_option( "-t", "--total", dest="total", action="store_true", help="count total of missing tracks", ) self._command.parser.add_option( "-a", "--album", dest="album", action="store_true", help=( "show missing album releases for artist instead of tracks; " "Defaults to only releases of type 'album'" ), ) self._command.parser.add_option( "--release-types", action="append", dest="release_types", help=( "comma-separated list of release types for missing albums " f"(valid: {', '.join(VALID_RELEASE_TYPES)})" ), ) self._command.parser.add_format_option() def commands(self): def _miss(lib, opts, args): self.config.set_args(opts) albms = self.config["album"].get() helper = self._missing_albums if albms else self._missing_tracks helper(lib, args) self._command.func = _miss return [self._command] def _missing_tracks(self, lib, query): """Print a listing of tracks missing from each album in the library matching query. """ albums = lib.albums(query) count = self.config["count"].get() total = self.config["total"].get() fmt = config["format_album" if count else "format_item"].get() if total: print(sum([_missing_count(a) for a in albums])) return # Default format string for count mode. if count: fmt += ": $missing" for album in albums: if count: if _missing_count(album): print_(format(album, fmt)) else: for item in self._missing(album): print_(format(item, fmt)) def _missing_albums(self, lib: Library, query: list[str]) -> None: """Print a listing of albums missing from each artist in the library matching query. """ query.append(MB_ARTIST_QUERY) # build dict mapping artist to set of their release group ids in library album_ids_by_artist = defaultdict(set) for album in lib.albums(query): # TODO(@snejus): Some releases have different `albumartist` for the # same `mb_albumartistid`. Since we're grouping by the combination # of these two fields, we end up processing the same # `mb_albumartistid` multiple times: calling MusicBrainz API and # reporting the same set of missing albums. Instead, we should # group by `mb_albumartistid` field only. artist = (album["albumartist"], album["mb_albumartistid"]) album_ids_by_artist[artist].add(album["mb_releasegroupid"]) total_missing = 0 release_types = [] for rt in self.config["release_types"].as_str_seq(): release_types.extend(rt.split(",")) calculating_total = self.config["total"].get() for (artist, artist_id), album_ids in album_ids_by_artist.items(): try: resp = self.mb_api.browse_release_groups( artist=artist_id, type="|".join(release_types), ) except requests.exceptions.RequestException: self._log.info( "Couldn't fetch info for artist '{}' ({})", artist, artist_id, exc_info=True, ) continue missing_titles = [ f"{artist} - {rg['title']}" for rg in resp if rg["id"] not in album_ids ] if calculating_total: total_missing += len(missing_titles) else: for title in missing_titles: print(title) if calculating_total: print(total_missing) def _missing(self, album: Album) -> Iterator[Item]: """Query MusicBrainz to determine items missing from `album`.""" if len(album.items()) == album.albumtotal: return # fetch missing items # TODO: Implement caching that without breaking other stuff data_source = album.get("data_source") or album.items()[0].get( "data_source", "MusicBrainz" ) if album_info := metadata_plugins.album_for_id( album.mb_albumid, data_source ): item_mbids = {x.mb_trackid for x in album.items()} for track_info in album_info.tracks: if track_info.track_id not in item_mbids: self._log.debug( "track {.track_id} in album {.album_id}", track_info, album_info, ) yield _item(track_info, album_info, album.id) ================================================ FILE: beetsplug/mpdstats.py ================================================ # This file is part of beets. # Copyright 2016, Peter Schnebel and Johann Klähn. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import time from typing import ClassVar import mpd from beets import config, plugins, ui from beets.dbcore import types from beets.dbcore.query import PathQuery from beets.util import displayable_path # If we lose the connection, how many times do we want to retry and how # much time should we wait between retries? RETRIES = 10 RETRY_INTERVAL = 5 DUPLICATE_PLAY_THRESHOLD = 10.0 mpd_config = config["mpd"] def is_url(path): """Try to determine if the path is an URL.""" if isinstance(path, bytes): # if it's bytes, then it's a path return False return path.split("://", 1)[0] in ["http", "https"] class MPDClientWrapper: def __init__(self, log): self._log = log self.music_directory = mpd_config["music_directory"].as_str() self.strip_path = mpd_config["strip_path"].as_str() # Ensure strip_path end with '/' if not self.strip_path.endswith("/"): self.strip_path += "/" self._log.debug("music_directory: {.music_directory}", self) self._log.debug("strip_path: {.strip_path}", self) self.client = mpd.MPDClient() def connect(self): """Connect to the MPD.""" host = mpd_config["host"].as_str() port = mpd_config["port"].get(int) if host[0] in ["/", "~"]: host = os.path.expanduser(host) self._log.info("connecting to {}:{}", host, port) try: self.client.connect(host, port) except OSError as e: raise ui.UserError(f"could not connect to MPD: {e}") password = mpd_config["password"].as_str() if password: try: self.client.password(password) except mpd.CommandError as e: raise ui.UserError(f"could not authenticate to MPD: {e}") def disconnect(self): """Disconnect from the MPD.""" self.client.close() self.client.disconnect() def get(self, command, retries=RETRIES): """Wrapper for requests to the MPD server. Tries to re-connect if the connection was lost (f.ex. during MPD's library refresh). """ try: return getattr(self.client, command)() except (OSError, mpd.ConnectionError) as err: self._log.error("{}", err) if retries <= 0: # if we exited without breaking, we couldn't reconnect in time :( raise ui.UserError("communication with MPD server failed") time.sleep(RETRY_INTERVAL) try: self.disconnect() except mpd.ConnectionError: pass self.connect() return self.get(command, retries=retries - 1) def currentsong(self): """Return the path to the currently playing song, along with its songid. Prefixes paths with the music_directory, to get the absolute path. In some cases, we need to remove the local path from MPD server, we replace 'strip_path' with ''. `strip_path` defaults to ''. """ result = None entry = self.get("currentsong") if "file" in entry: if not is_url(entry["file"]): file = entry["file"] if file.startswith(self.strip_path): file = file[len(self.strip_path) :] result = os.path.join(self.music_directory, file) else: result = entry["file"] self._log.debug("returning: {}", result) return result, entry.get("id") def status(self): """Return the current status of the MPD.""" return self.get("status") def events(self): """Return list of events. This may block a long time while waiting for an answer from MPD. """ return self.get("idle") class MPDStats: def __init__(self, lib, log): self.lib = lib self._log = log self.do_rating = mpd_config["rating"].get(bool) self.rating_mix = mpd_config["rating_mix"].get(float) self.played_ratio_threshold = mpd_config["played_ratio_threshold"].get( float ) self.now_playing = None self.mpd = MPDClientWrapper(log) def rating(self, play_count, skip_count, rating, skipped): """Calculate a new rating for a song based on play count, skip count, old rating and the fact if it was skipped or not. """ if skipped: rolling = rating - rating / 2.0 else: rolling = rating + (1.0 - rating) / 2.0 stable = (play_count + 1.0) / (play_count + skip_count + 2.0) return self.rating_mix * stable + (1.0 - self.rating_mix) * rolling def get_item(self, path): """Return the beets item related to path.""" query = PathQuery("path", path) item = self.lib.items(query).get() if item: return item else: self._log.info("item not found: {}", displayable_path(path)) def update_item(self, item, attribute, value=None, increment=None): """Update the beets item. Set attribute to value or increment the value of attribute. If the increment argument is used the value is cast to the corresponding type. """ if item is None: return if increment is not None: item.load() value = type(increment)(item.get(attribute, 0)) + increment if value is not None: item[attribute] = value item.store() self._log.debug( "updated: {} = {} [{.filepath}]", attribute, item[attribute], item, ) def update_rating(self, item, skipped): """Update the rating for a beets item. The `item` can either be a beets `Item` or None. If the item is None, nothing changes. """ if item is None: return item.load() rating = self.rating( int(item.get("play_count", 0)), int(item.get("skip_count", 0)), float(item.get("rating", 0.5)), skipped, ) self.update_item(item, "rating", rating) def handle_song_change(self, song): """Determine if a song was skipped or not and update its attributes. To this end the difference between the song's supposed end time and the current time is calculated. If it's greater than a threshold, the song is considered skipped. Returns whether the change was manual (skipped previous song or not) """ elapsed = song["elapsed_at_start"] + (time.time() - song["started"]) skipped = elapsed / song["duration"] < self.played_ratio_threshold if skipped: self.handle_skipped(song) else: self.handle_played(song) if self.do_rating: self.update_rating(song["beets_item"], skipped) return skipped def handle_played(self, song): """Updates the play count of a song.""" self.update_item(song["beets_item"], "play_count", increment=1) self._log.info("played {}", displayable_path(song["path"])) def handle_skipped(self, song): """Updates the skip count of a song.""" self.update_item(song["beets_item"], "skip_count", increment=1) self._log.info("skipped {}", displayable_path(song["path"])) def on_stop(self, status): self._log.info("stop") # if the current song stays the same it means that we stopped on the # current track and should not record a skip. if self.now_playing and self.now_playing["id"] != status.get("songid"): self.handle_song_change(self.now_playing) self.now_playing = None def on_pause(self, status): self._log.info("pause") self.now_playing = None def on_play(self, status): path, songid = self.mpd.currentsong() if not path: return played, duration = map(int, status["time"].split(":", 1)) if self.now_playing: if self.now_playing["path"] != path: self.handle_song_change(self.now_playing) else: # In case we got mpd play event with same song playing # multiple times, # assume low diff means redundant second play event # after natural song start. diff = abs(time.time() - self.now_playing["started"]) if diff <= DUPLICATE_PLAY_THRESHOLD: return if self.now_playing["path"] == path and played == 0: self.handle_song_change(self.now_playing) if is_url(path): self._log.info("playing stream {}", displayable_path(path)) self.now_playing = None return self._log.info("playing {}", displayable_path(path)) self.now_playing = { "started": time.time(), "elapsed_at_start": played, "duration": duration, "path": path, "id": songid, "beets_item": self.get_item(path), } self.update_item( self.now_playing["beets_item"], "last_played", value=int(time.time()), ) def run(self): self.mpd.connect() events = ["player"] while True: if "player" in events: status = self.mpd.status() handler = getattr(self, f"on_{status['state']}", None) if handler: handler(status) else: self._log.debug('unhandled status "{}"', status) events = self.mpd.events() class MPDStatsPlugin(plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "play_count": types.INTEGER, "skip_count": types.INTEGER, "last_played": types.DATE, "rating": types.FLOAT, } def __init__(self): super().__init__() mpd_config.add( { "music_directory": config["directory"].as_filename(), "strip_path": "", "rating": True, "rating_mix": 0.75, "host": os.environ.get("MPD_HOST", "localhost"), "port": int(os.environ.get("MPD_PORT", 6600)), "password": "", "played_ratio_threshold": 0.85, } ) mpd_config["password"].redact = True def commands(self): cmd = ui.Subcommand( "mpdstats", help="run a MPD client to gather play statistics" ) cmd.parser.add_option( "--host", dest="host", type="string", help="set the hostname of the server to connect to", ) cmd.parser.add_option( "--port", dest="port", type="int", help="set the port of the MPD server to connect to", ) cmd.parser.add_option( "--password", dest="password", type="string", help="set the password of the MPD server to connect to", ) def func(lib, opts, args): mpd_config.set_args(opts) # Overrides for MPD settings. if opts.host: mpd_config["host"] = opts.host.decode("utf-8") if opts.port: mpd_config["host"] = int(opts.port) if opts.password: mpd_config["password"] = opts.password.decode("utf-8") try: MPDStats(lib, self._log).run() except KeyboardInterrupt: pass cmd.func = func return [cmd] ================================================ FILE: beetsplug/mpdupdate.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates an MPD index whenever the library is changed. Put something like the following in your config.yaml to configure: mpd: host: localhost port: 6600 password: seekrit """ import os import socket from beets import config from beets.plugins import BeetsPlugin # No need to introduce a dependency on an MPD library for such a # simple use case. Here's a simple socket abstraction to make things # easier. class BufferedSocket: """Socket abstraction that allows reading by line.""" def __init__(self, host, port, sep=b"\n"): if host[0] in ["/", "~"]: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(os.path.expanduser(host)) else: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) self.buf = b"" self.sep = sep def readline(self): while self.sep not in self.buf: data = self.sock.recv(1024) if not data: break self.buf += data if self.sep in self.buf: res, self.buf = self.buf.split(self.sep, 1) return res + self.sep else: return b"" def send(self, data): self.sock.send(data) def close(self): self.sock.close() class MPDUpdatePlugin(BeetsPlugin): def __init__(self): super().__init__() config["mpd"].add( { "host": os.environ.get("MPD_HOST", "localhost"), "port": int(os.environ.get("MPD_PORT", 6600)), "password": "", } ) config["mpd"]["password"].redact = True # For backwards compatibility, use any values from the # plugin-specific "mpdupdate" section. for key in config["mpd"].keys(): if self.config[key].exists(): config["mpd"][key] = self.config[key].get() self.register_listener("database_change", self.db_change) def db_change(self, lib, model): self.register_listener("cli_exit", self.update) def update(self, lib): self.update_mpd( config["mpd"]["host"].as_str(), config["mpd"]["port"].get(int), config["mpd"]["password"].as_str(), ) def update_mpd(self, host="localhost", port=6600, password=None): """Sends the "update" command to the MPD server indicated, possibly authenticating with a password first. """ self._log.info("Updating MPD database...") try: s = BufferedSocket(host, port) except OSError: self._log.warning("MPD connection failed", exc_info=True) return resp = s.readline() if b"OK MPD" not in resp: self._log.warning("MPD connection failed: {0!r}", resp) return if password: s.send(f'password "{password}"\n'.encode()) resp = s.readline() if b"OK" not in resp: self._log.warning("Authentication failed: {0!r}", resp) s.send(b"close\n") s.close() return s.send(b"update\n") resp = s.readline() if b"updating_db" not in resp: self._log.warning("Update failed: {0!r}", resp) s.send(b"close\n") s.close() self._log.info("Database updated.") ================================================ FILE: beetsplug/musicbrainz.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Searches for albums in the MusicBrainz database.""" from __future__ import annotations from collections import Counter from contextlib import suppress from functools import cached_property from itertools import product from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urljoin from confuse.exceptions import NotFoundError import beets import beets.autotag.hooks from beets import config, plugins, util from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin from beets.util.deprecation import deprecate_for_user from beets.util.id_extractors import extract_release_id from ._utils.musicbrainz import MusicBrainzAPIMixin from ._utils.requests import HTTPNotFoundError if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Item from beets.metadata_plugins import QueryType, SearchParams from ._typing import JSONDict VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377" BASE_URL = "https://musicbrainz.org/" SKIPPED_TRACKS = ["[data track]"] FIELDS_TO_MB_KEYS = { "barcode": "barcode", "catalognum": "catno", "country": "country", "label": "label", "media": "format", "year": "date", "tracks": "tracks", "alias": "alias", } BROWSE_INCLUDES = [ "artist-credits", "work-rels", "artist-rels", "recording-rels", "release-rels", ] BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 def _preferred_alias( aliases: list[JSONDict], languages: list[str] | None = None ) -> JSONDict | None: """Given a list of alias structures for an artist credit, select and return the user's preferred alias or None if no matching """ if not aliases: return None # Only consider aliases that have locales set. valid_aliases = [a for a in aliases if "locale" in a] # Get any ignored alias types and lower case them to prevent case issues ignored_alias_types = config["import"]["ignored_alias_types"].as_str_seq() ignored_alias_types = [a.lower() for a in ignored_alias_types] # Search configured locales in order. if languages is None: languages = config["import"]["languages"].as_str_seq() for locale in languages: # Find matching primary aliases for this locale that are not # being ignored for alias in valid_aliases: if ( alias["locale"] == locale and alias.get("primary") and (alias.get("type") or "").lower() not in ignored_alias_types ): return alias return None def _key_with_preferred_alias(obj: JSONDict, key: str) -> str: alias = _preferred_alias(obj.get("aliases", ())) return alias["name"] if alias else obj[key] def _multi_artist_credit( credit: list[JSONDict], include_join_phrase: bool ) -> tuple[list[str], list[str], list[str]]: """Given a list representing an ``artist-credit`` block, accumulate data into a triple of joined artist name lists: canonical, sort, and credit. """ artist_parts = [] artist_sort_parts = [] artist_credit_parts = [] for el in credit: alias = _preferred_alias(el["artist"].get("aliases", ())) # An artist. cur_artist_name = alias["name"] if alias else el["artist"]["name"] artist_parts.append(cur_artist_name) # Artist sort name. if alias: artist_sort_parts.append(alias["sort-name"]) elif "sort-name" in el["artist"]: artist_sort_parts.append(el["artist"]["sort-name"]) else: artist_sort_parts.append(cur_artist_name) # Artist credit. if "name" in el: artist_credit_parts.append(el["name"]) else: artist_credit_parts.append(cur_artist_name) if include_join_phrase and (joinphrase := el.get("joinphrase")): artist_parts.append(joinphrase) artist_sort_parts.append(joinphrase) artist_credit_parts.append(joinphrase) return ( artist_parts, artist_sort_parts, artist_credit_parts, ) def track_url(trackid: str) -> str: return urljoin(BASE_URL, f"recording/{trackid}") def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]: """Given a list representing an ``artist-credit`` block, flatten the data into a triple of joined artist name strings: canonical, sort, and credit. """ artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit( credit, include_join_phrase=True ) return ( "".join(artist_parts), "".join(artist_sort_parts), "".join(artist_credit_parts), ) def _artist_ids(credit: list[JSONDict]) -> list[str]: """ Given a list representing an ``artist-credit``, return a list of artist IDs """ artist_ids: list[str] = [] for el in credit: if isinstance(el, dict): artist_ids.append(el["artist"]["id"]) return artist_ids def _get_related_artist_names(relations, relation_type): """Given a list representing the artist relationships extract the names of the remixers and concatenate them. """ related_artists = [] for relation in relations: if relation["type"] == relation_type: related_artists.append(relation["artist"]["name"]) return ", ".join(related_artists) def album_url(albumid: str) -> str: return urljoin(BASE_URL, f"release/{albumid}") def _preferred_release_event( release: dict[str, Any], ) -> tuple[str | None, str | None]: """Given a release, select and return the user's preferred release event as a tuple of (country, release_date). Fall back to the default release event if a preferred event is not found. """ preferred_countries: Sequence[str] = config["match"]["preferred"][ "countries" ].as_str_seq() for country in preferred_countries: for event in release.get("release-events", {}): try: if area := event.get("area"): if country in area["iso-3166-1-codes"]: return country, event["date"] except KeyError: pass return release.get("country"), release.get("date") def _set_date_str( info: beets.autotag.hooks.AlbumInfo, date_str: str, original: bool = False, ): """Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo object, set the object's release date fields appropriately. If `original`, then set the original_year, etc., fields. """ if date_str: date_parts = date_str.split("-") for key in ("year", "month", "day"): if date_parts: date_part = date_parts.pop(0) try: date_num = int(date_part) except ValueError: continue if original: key = f"original_{key}" setattr(info, key, date_num) def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo ) -> beets.autotag.hooks.AlbumInfo: """ Merges a pseudo release with its actual release. This implementation is naive, it doesn't overwrite fields, like status or ids. According to the ticket PICARD-145, the main release id should be used. But the ticket has been in limbo since over a decade now. It also suggests the introduction of the tag `musicbrainz_pseudoreleaseid`, but as of this field can't be found in any official Picard docs, hence why we did not implement that for now. """ merged = pseudo.copy() from_actual = { k: actual[k] for k in [ "media", "mediums", "country", "catalognum", "year", "month", "day", "original_year", "original_month", "original_day", "label", "barcode", "asin", "style", "genre", ] } merged.update(from_actual) return merged class MusicBrainzPlugin( MusicBrainzAPIMixin, SearchApiMetadataSourcePlugin[IDResponse] ): @cached_property def genres_field(self) -> str: return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s" def __init__(self): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. """ super().__init__() self.config.add( { "genres": False, "genres_tag": "genre", "external_ids": { "discogs": False, "bandcamp": False, "spotify": False, "deezer": False, "tidal": False, }, "extra_tags": [], }, ) # TODO: Remove in 3.0.0 with suppress(NotFoundError): self.config["search_limit"] = self.config["match"][ "searchlimit" ].get() deprecate_for_user( self._log, "'musicbrainz.searchlimit' configuration option", "'musicbrainz.search_limit'", ) def track_info( self, recording: JSONDict, index: int | None = None, medium: int | None = None, medium_index: int | None = None, medium_total: int | None = None, ) -> beets.autotag.hooks.TrackInfo: """Translates a MusicBrainz recording result dictionary into a beets ``TrackInfo`` object. Three parameters are optional and are used only for tracks that appear on releases (non-singletons): ``index``, the overall track number; ``medium``, the disc number; ``medium_index``, the track's index on its medium; ``medium_total``, the number of tracks on the medium. Each number is a 1-based index. """ title = _key_with_preferred_alias(recording, key="title") info = beets.autotag.hooks.TrackInfo( title=title, track_id=recording["id"], index=index, medium=medium, medium_index=medium_index, medium_total=medium_total, data_source=self.data_source, data_url=track_url(recording["id"]), ) if recording.get("artist-credit"): # Get the artist names. ( info.artist, info.artist_sort, info.artist_credit, ) = _flatten_artist_credit(recording["artist-credit"]) ( info.artists, info.artists_sort, info.artists_credit, ) = _multi_artist_credit( recording["artist-credit"], include_join_phrase=False ) info.artists_ids = _artist_ids(recording["artist-credit"]) info.artist_id = info.artists_ids[0] if recording.get("artist-relations"): info.remixer = _get_related_artist_names( recording["artist-relations"], relation_type="remixer" ) if recording.get("length"): info.length = int(recording["length"]) / 1000.0 info.trackdisambig = recording.get("disambiguation") if recording.get("isrcs"): info.isrc = ";".join(recording["isrcs"]) lyricist = [] composer = [] composer_sort = [] for work_relation in recording.get("work-relations", ()): if work_relation["type"] != "performance": continue info.work = work_relation["work"]["title"] info.mb_workid = work_relation["work"]["id"] if "disambiguation" in work_relation["work"]: info.work_disambig = work_relation["work"]["disambiguation"] for artist_relation in work_relation["work"].get( "artist-relations", () ): if "type" in artist_relation: type = artist_relation["type"] if type == "lyricist": lyricist.append(artist_relation["artist"]["name"]) elif type == "composer": composer.append(artist_relation["artist"]["name"]) composer_sort.append( artist_relation["artist"]["sort-name"] ) if lyricist: info.lyricist = ", ".join(lyricist) if composer: info.composer = ", ".join(composer) info.composer_sort = ", ".join(composer_sort) arranger = [] for artist_relation in recording.get("artist-relations", ()): if "type" in artist_relation: type = artist_relation["type"] if type == "arranger": arranger.append(artist_relation["artist"]["name"]) if arranger: info.arranger = ", ".join(arranger) # Supplementary fields provided by plugins extra_trackdatas = plugins.send("mb_track_extract", data=recording) for extra_trackdata in extra_trackdatas: info.update(extra_trackdata) return info def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: """Takes a MusicBrainz release result dictionary and returns a beets AlbumInfo object containing the interesting data about that release. """ # Get artist name using join phrases. artist_name, artist_sort_name, artist_credit_name = ( _flatten_artist_credit(release["artist-credit"]) ) ( artists_names, artists_sort_names, artists_credit_names, ) = _multi_artist_credit( release["artist-credit"], include_join_phrase=False ) ntracks = sum(len(m.get("tracks", [])) for m in release["media"]) # The MusicBrainz API omits 'relations' # when the release has more than 500 tracks. So we use browse_recordings # on chunks of tracks to recover the same information in this case. if ntracks > BROWSE_MAXTRACKS: self._log.debug("Album {} has too many tracks", release["id"]) recording_list = [] for i in range(0, ntracks, BROWSE_CHUNKSIZE): self._log.debug("Retrieving tracks starting at {}", i) recording_list.extend( self.mb_api.browse_recordings( release=release["id"], limit=BROWSE_CHUNKSIZE, includes=BROWSE_INCLUDES, offset=i, ) ) recording_by_id = {r["id"]: r for r in recording_list} for medium in release["media"]: for track in medium["tracks"]: track["recording"] = recording_by_id[ track["recording"]["id"] ] # Basic info. track_infos = [] index = 0 for medium in release["media"]: disctitle = medium.get("title") format = medium.get("format") if format in config["match"]["ignored_media"].as_str_seq(): continue all_tracks = medium.get("tracks", []) if ( "data-tracks" in medium and not config["match"]["ignore_data_tracks"] ): all_tracks += medium["data-tracks"] track_count = len(all_tracks) if "pregap" in medium: all_tracks.insert(0, medium["pregap"]) for track in all_tracks: if ( "title" in track["recording"] and track["recording"]["title"] in SKIPPED_TRACKS ): continue if ( "video" in track["recording"] and track["recording"]["video"] and config["match"]["ignore_video_tracks"] ): continue # Basic information from the recording. index += 1 ti = self.track_info( track["recording"], index, int(medium["position"]), int(track["position"]), track_count, ) ti.release_track_id = track["id"] ti.disctitle = disctitle ti.media = format ti.track_alt = track["number"] # Prefer track data, where present, over recording data except # if a preferred recording alias is available. if track.get("title") and not _preferred_alias( track["recording"].get("aliases", ()) ): ti.title = track["title"] if track.get("artist-credit"): # Get the artist names. ( ti.artist, ti.artist_sort, ti.artist_credit, ) = _flatten_artist_credit(track["artist-credit"]) ( ti.artists, ti.artists_sort, ti.artists_credit, ) = _multi_artist_credit( track["artist-credit"], include_join_phrase=False ) ti.artists_ids = _artist_ids(track["artist-credit"]) ti.artist_id = ti.artists_ids[0] if track.get("length"): ti.length = int(track["length"]) / (1000.0) track_infos.append(ti) album_artist_ids = _artist_ids(release["artist-credit"]) release_title = _key_with_preferred_alias(release, key="title") info = beets.autotag.hooks.AlbumInfo( album=release_title, album_id=release["id"], artist=artist_name, artist_id=album_artist_ids[0], artists=artists_names, artists_ids=album_artist_ids, tracks=track_infos, mediums=len(release["media"]), artist_sort=artist_sort_name, artists_sort=artists_sort_names, artist_credit=artist_credit_name, artists_credit=artists_credit_names, data_source=self.data_source, data_url=album_url(release["id"]), barcode=release.get("barcode"), ) info.va = info.artist_id == VARIOUS_ARTISTS_ID if info.va: va_name = config["va_name"].as_str() info.artist = va_name info.artist_sort = va_name info.artists = [va_name] info.artists_sort = [va_name] info.artist_credit = va_name info.artists_credit = [va_name] info.asin = release.get("asin") info.releasegroup_id = release["release-group"]["id"] info.albumstatus = release.get("status") if release["release-group"].get("title"): info.release_group_title = _key_with_preferred_alias( release["release-group"], key="title" ) # Get the disambiguation strings at the release and release group level. if release["release-group"].get("disambiguation"): info.releasegroupdisambig = release["release-group"].get( "disambiguation" ) if release.get("disambiguation"): info.albumdisambig = release.get("disambiguation") if reltype := release["release-group"].get("primary-type"): info.albumtype = reltype.lower() # Set the new-style "primary" and "secondary" release types. albumtypes = [] if "primary-type" in release["release-group"]: rel_primarytype = release["release-group"]["primary-type"] if rel_primarytype: albumtypes.append(rel_primarytype.lower()) if "secondary-types" in release["release-group"]: if release["release-group"]["secondary-types"]: for sec_type in release["release-group"]["secondary-types"]: albumtypes.append(sec_type.lower()) info.albumtypes = albumtypes # Release events. info.country, release_date = _preferred_release_event(release) release_group_date = release["release-group"].get("first-release-date") if not release_date: # Fall back if release-specific date is not available. release_date = release_group_date if release_date: _set_date_str(info, release_date, False) _set_date_str(info, release_group_date, True) # Label name. if release.get("label-info"): label_info = release["label-info"][0] if label_info.get("label"): label = label_info["label"]["name"] if label != "[no label]": info.label = label info.catalognum = label_info.get("catalog-number") # Text representation data. if release.get("text-representation"): rep = release["text-representation"] info.script = rep.get("script") info.language = rep.get("language") # Media (format). if release["media"]: # If all media are the same, use that medium name if len({m.get("format") for m in release["media"]}) == 1: info.media = release["media"][0].get("format") # Otherwise, let's just call it "Media" else: info.media = "Media" if self.config["genres"]: sources = [ release["release-group"].get(self.genres_field, []), release.get(self.genres_field, []), ] genres: Counter[str] = Counter() for source in sources: for genreitem in source: genres[genreitem["name"]] += int(genreitem["count"]) if genres: info.genres = [ genre for genre, _count in sorted( genres.items(), key=lambda g: -g[1] ) ] # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() wanted_sources = { site for site, wanted in external_ids.items() if wanted } if wanted_sources and (url_rels := release.get("url-relations")): urls = {} for source, url in product(wanted_sources, url_rels): if f"{source}.com" in (target := url["url"]["resource"]): urls[source] = target self._log.debug( "Found link to {} release via MusicBrainz", source.capitalize(), ) for source, url in urls.items(): setattr( info, f"{source}_album_id", extract_release_id(source, url) ) extra_albumdatas = plugins.send("mb_album_extract", data=release) for extra_albumdata in extra_albumdatas: info.update(extra_albumdata) return info @cached_property def extra_mb_field_by_tag(self) -> dict[str, str]: """Map configured extra tags to their MusicBrainz API field names. Process user configuration to determine which additional MusicBrainz fields should be included in search queries. """ mb_field_by_tag = { t: FIELDS_TO_MB_KEYS[t] for t in self.config["extra_tags"].as_str_seq() if t in FIELDS_TO_MB_KEYS } if mb_field_by_tag: self._log.debug("Additional search terms: {}", mb_field_by_tag) return mb_field_by_tag def get_album_criteria( self, items: Sequence[Item], artist: str, album: str, va_likely: bool ) -> dict[str, str]: criteria = {"release": album} | ( {"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist} ) for tag, mb_field in self.extra_mb_field_by_tag.items(): if tag == "tracks": value = str(len(items)) elif tag == "alias": value = album else: most_common, _ = util.plurality(i.get(tag) for i in items) value = str(most_common) if tag == "catalognum": value = value.replace(" ", "") criteria[mb_field] = value return criteria def get_search_query_with_filters( self, query_type: QueryType, items: Sequence[Item], artist: str, name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: """Build MusicBrainz criteria filters for album and recording search.""" if query_type == "album": criteria = self.get_album_criteria(items, artist, name, va_likely) else: criteria = {"artist": artist, "recording": name, "alias": name} return "", { k: _v for k, v in criteria.items() if (_v := v.lower().strip()) } def get_search_response(self, params: SearchParams) -> Sequence[IDResponse]: """Search MusicBrainz and return release or recording result mappings.""" mb_entity: Literal["release", "recording"] = ( "release" if params.query_type == "album" else "recording" ) return self.mb_api.search( mb_entity, dict(params.filters), limit=params.limit ) def album_for_id( self, album_id: str ) -> beets.autotag.hooks.AlbumInfo | None: """Fetches an album by its MusicBrainz ID and returns an AlbumInfo object or None if the album is not found. May raise a MusicBrainzAPIError. """ self._log.debug("Requesting MusicBrainz release {}", album_id) if not (albumid := self._extract_id(album_id)): self._log.debug("Invalid MBID ({}).", album_id) return None # A 404 error here is fine. e.g. re-importing a release that has # been deleted on MusicBrainz. try: res = self.mb_api.get_release(albumid) except HTTPNotFoundError: self._log.debug("Release {} not found on MusicBrainz.", albumid) return None # resolve linked release relations actual_res = None if res.get("status") == "Pseudo-Release" and ( relations := res.get("release-relations") ): for rel in relations: if ( rel["type"] == "transl-tracklisting" and rel["direction"] == "backward" ): actual_res = self.mb_api.get_release(rel["release"]["id"]) # release is potentially a pseudo release release = self.album_info(res) # should be None unless we're dealing with a pseudo release if actual_res is not None: actual_release = self.album_info(actual_res) return _merge_pseudo_and_actual_album(release, actual_release) else: return release def track_for_id( self, track_id: str ) -> beets.autotag.hooks.TrackInfo | None: """Fetches a track by its MusicBrainz ID. Returns a TrackInfo object or None if no track is found. May raise a MusicBrainzAPIError. """ if not (trackid := self._extract_id(track_id)): self._log.debug("Invalid MBID ({}).", track_id) return None with suppress(HTTPNotFoundError): return self.track_info(self.mb_api.get_recording(trackid)) return None ================================================ FILE: beetsplug/parentwork.py ================================================ # This file is part of beets. # Copyright 2017, Dorian Soergel. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Gets parent work, its disambiguation and id, composer, composer sort name and work composition date """ from __future__ import annotations from typing import Any import requests from beets import ui from beets.plugins import BeetsPlugin from ._utils.musicbrainz import MusicBrainzAPIMixin class ParentWorkPlugin(MusicBrainzAPIMixin, BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "auto": False, "force": False, } ) if self.config["auto"]: self.import_stages = [self.imported] def commands(self): def func(lib, opts, args): self.config.set_args(opts) force_parent = self.config["force"].get(bool) write = ui.should_write() for item in lib.items(args): changed = self.find_work(item, force_parent, verbose=True) if changed: item.store() if write: item.try_write() command = ui.Subcommand( "parentwork", help="fetch parent works, composers and dates" ) command.parser.add_option( "-f", "--force", dest="force", action="store_true", default=None, help="re-fetch when parent work is already present", ) command.func = func return [command] def imported(self, session, task): """Import hook for fetching parent works automatically.""" force_parent = self.config["force"].get(bool) for item in task.imported_items(): self.find_work(item, force_parent, verbose=False) item.store() def get_info(self, item, work_info): """Given the parent work info dict, fetch parent_composer, parent_composer_sort, parentwork, parentwork_disambig, mb_workid and composer_ids. """ parent_composer = [] parent_composer_sort = [] parentwork_info = {} composer_exists = False for artist in work_info.get("artist-relations", []): if artist["type"] == "composer": composer_exists = True parent_composer.append(artist["artist"]["name"]) parent_composer_sort.append(artist["artist"]["sort-name"]) if "end" in artist.keys(): parentwork_info["parentwork_date"] = artist["end"] parentwork_info["parent_composer"] = ", ".join(parent_composer) parentwork_info["parent_composer_sort"] = ", ".join( parent_composer_sort ) if not composer_exists: self._log.debug( "no composer for {}; add one at " "https://musicbrainz.org/work/{}", item, work_info["id"], ) parentwork_info["parentwork"] = work_info["title"] parentwork_info["mb_parentworkid"] = work_info["id"] if "disambiguation" in work_info: parentwork_info["parentwork_disambig"] = work_info["disambiguation"] else: parentwork_info["parentwork_disambig"] = None return parentwork_info def find_work(self, item, force, verbose): """Finds the parent work of a recording and populates the tags accordingly. The parent work is found recursively, by finding the direct parent repeatedly until there are no more links in the chain. We return the final, topmost work in the chain. Namely, the tags parentwork, parentwork_disambig, mb_parentworkid, parent_composer, parent_composer_sort and work_date are populated. """ if not item.mb_workid: self._log.info( "No work for {0}, add one at https://musicbrainz.org/recording/{0.mb_trackid}", item, ) return hasparent = hasattr(item, "parentwork") work_changed = True if hasattr(item, "parentwork_workid_current"): work_changed = item.parentwork_workid_current != item.mb_workid if force or not hasparent or work_changed: try: work_info, work_date = self.find_parentwork_info(item.mb_workid) except requests.exceptions.RequestException: self._log.debug("error fetching work", item, exc_info=True) return parent_info = self.get_info(item, work_info) parent_info["parentwork_workid_current"] = item.mb_workid if "parent_composer" in parent_info: self._log.debug( "Work fetched: {} - {}", parent_info["parentwork"], parent_info["parent_composer"], ) else: self._log.debug( "Work fetched: {} - no parent composer", parent_info["parentwork"], ) elif hasparent: self._log.debug("{}: Work present, skipping", item) return # apply all non-null values to the item for key, value in parent_info.items(): if value: item[key] = value if work_date: item["work_date"] = work_date if verbose: return ui.show_model_changes( item, fields=[ "parentwork", "parentwork_disambig", "mb_parentworkid", "parent_composer", "parent_composer_sort", "work_date", "parentwork_workid_current", "parentwork_date", ], ) def find_parentwork_info( self, mb_workid: str ) -> tuple[dict[str, Any], str | None]: """Get the MusicBrainz information dict about a parent work, including the artist relations, and the composition date for a work's parent work. """ work_date = None parent_id: str | None = mb_workid while parent_id: current_id = parent_id work_info = self.mb_api.get_work( current_id, includes=["work-rels", "artist-rels"] ) work_date = work_date or next( ( end for a in work_info.get("artist-relations", []) if a["type"] == "composer" and (end := a.get("end")) ), None, ) parent_id = next( ( w["work"]["id"] for w in work_info.get("work-relations", []) if w["type"] == "parts" and w["direction"] == "backward" ), None, ) return work_info, work_date ================================================ FILE: beetsplug/permissions.py ================================================ """Fixes file permissions after the file gets written on import. Put something like the following in your config.yaml to configure: permissions: file: 644 dir: 755 """ import os import stat from beets import config from beets.plugins import BeetsPlugin from beets.util import ancestry, displayable_path, syspath def convert_perm(perm): """Convert a string to an integer, interpreting the text as octal. Or, if `perm` is an integer, reinterpret it as an octal number that has been "misinterpreted" as decimal. """ if isinstance(perm, int): perm = str(perm) return int(perm, 8) def check_permissions(path, permission): """Check whether the file's permissions equal the given vector. Return a boolean. """ return oct(stat.S_IMODE(os.stat(syspath(path)).st_mode)) == oct(permission) def assert_permissions(path, permission, log): """Check whether the file's permissions are as expected, otherwise, log a warning message. Return a boolean indicating the match, like `check_permissions`. """ if not check_permissions(path, permission): log.warning("could not set permissions on {}", displayable_path(path)) log.debug( "set permissions to {}, but permissions are now {}", permission, os.stat(syspath(path)).st_mode & 0o777, ) def dirs_in_library(library, item): """Creates a list of ancestor directories in the beets library path.""" return [ ancestor for ancestor in ancestry(item) if ancestor.startswith(library) ][1:] class Permissions(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. self.config.add( { "file": "644", "dir": "755", } ) self.register_listener("item_imported", self.fix) self.register_listener("album_imported", self.fix) self.register_listener("art_set", self.fix_art) def fix(self, lib, item=None, album=None): """Fix the permissions for an imported Item or Album.""" files = [] dirs = set() if item: files.append(item.path) dirs.update(dirs_in_library(lib.directory, item.path)) elif album: for album_item in album.items(): files.append(album_item.path) dirs.update(dirs_in_library(lib.directory, album_item.path)) self.set_permissions(files=files, dirs=dirs) def fix_art(self, album): """Fix the permission for Album art file.""" if album.artpath: self.set_permissions(files=[album.artpath]) def set_permissions(self, files=[], dirs=[]): # Get the configured permissions. The user can specify this either a # string (in YAML quotes) or, for convenience, as an integer so the # quotes can be omitted. In the latter case, we need to reinterpret the # integer as octal, not decimal. file_perm = config["permissions"]["file"].get() dir_perm = config["permissions"]["dir"].get() file_perm = convert_perm(file_perm) dir_perm = convert_perm(dir_perm) for path in files: # Changing permissions on the destination file. self._log.debug( "setting file permissions on {}", displayable_path(path), ) if not check_permissions(path, file_perm): os.chmod(syspath(path), file_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, file_perm, self._log) # Change permissions for the directories. for path in dirs: # Changing permissions on the destination directory. self._log.debug( "setting directory permissions on {}", displayable_path(path), ) if not check_permissions(path, dir_perm): os.chmod(syspath(path), dir_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, dir_perm, self._log) ================================================ FILE: beetsplug/play.py ================================================ # This file is part of beets. # Copyright 2016, David Hamp-Gonsalves # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Send the results of a query to the configured music player as a playlist.""" import random import shlex import subprocess from os.path import relpath from beets import config, ui, util from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import PromptChoice, get_temp_filename from beets.util.color import colorize # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. ARGS_MARKER = "$args" # Indicate where the playlist file (with absolute path) should be inserted into # the command string. If this is missing, its placed at the end, but before # arguments. PLS_MARKER = "$playlist" def play( command_str, selection, paths, open_args, log, item_type="track", keep_open=False, ): """Play items in paths with command_str and optional arguments. If keep_open, return to beets, otherwise exit once command runs. """ # Print number of tracks or albums to be played, log command to be run. item_type += "s" if len(selection) > 1 else "" ui.print_(f"Playing {len(selection)} {item_type}.") log.debug("executing command: {} {!r}", command_str, open_args) try: if keep_open: command = shlex.split(command_str) command = command + open_args subprocess.call(command) else: util.interactive_open(open_args, command_str) except OSError as exc: raise ui.UserError(f"Could not play the query: {exc}") class PlayPlugin(BeetsPlugin): def __init__(self): super().__init__() config["play"].add( { "command": None, "use_folders": False, "relative_to": None, "raw": False, "warning_threshold": 100, "bom": False, } ) self.register_listener( "before_choose_candidate", self.before_choose_candidate_listener ) def commands(self): play_command = Subcommand( "play", help="send music to a player as a playlist" ) play_command.parser.add_album_option() play_command.parser.add_option( "-A", "--args", action="store", help="add additional arguments to the command", ) play_command.parser.add_option( "-R", "--randomize", action="store_true", help="randomize the order of playlist entries", ) play_command.parser.add_option( "-y", "--yes", action="store_true", help="skip the warning threshold", ) play_command.func = self._play_command return [play_command] def _play_command(self, lib, opts, args): """The CLI command function for `beet play`. Create a list of paths from query, determine if tracks or albums are to be played. """ use_folders = config["play"]["use_folders"].get(bool) relative_to = config["play"]["relative_to"].get() if relative_to: relative_to = util.normpath(relative_to) # Perform search by album and add folders rather than tracks to # playlist. if opts.album: selection = lib.albums(args) paths = [] sort = lib.get_default_album_sort() for album in selection: if use_folders: paths.append(album.item_dir()) else: paths.extend(item.path for item in sort.sort(album.items())) item_type = "album" # Perform item query and add tracks to playlist. else: selection = lib.items(args) paths = [item.path for item in selection] item_type = "track" if relative_to: paths = [relpath(path, relative_to) for path in paths] if not selection: ui.print_(colorize("text_warning", f"No {item_type} to play.")) return if opts.randomize: random.shuffle(paths) open_args = self._playlist_or_paths(paths) open_args_str = [ p.decode("utf-8") for p in self._playlist_or_paths(paths) ] command_str = self._command_str(opts.args) if PLS_MARKER in command_str: if not config["play"]["raw"]: command_str = command_str.replace( PLS_MARKER, "".join(open_args_str) ) self._log.debug( "command altered by PLS_MARKER to: {}", command_str ) open_args = [] else: command_str = command_str.replace(PLS_MARKER, " ") # Check if the selection exceeds configured threshold. If True, # cancel, otherwise proceed with play command. if opts.yes or not self._exceeds_threshold( selection, command_str, open_args, item_type ): play(command_str, selection, paths, open_args, self._log, item_type) def _command_str(self, args=None): """Create a command string from the config command and optional args.""" command_str = config["play"]["command"].get() if not command_str: return util.open_anything() # Add optional arguments to the player command. if args: if ARGS_MARKER in command_str: return command_str.replace(ARGS_MARKER, args) else: return f"{command_str} {args}" else: # Don't include the marker in the command. return command_str.replace(f" {ARGS_MARKER}", "") def _playlist_or_paths(self, paths): """Return either the raw paths of items or a playlist of the items.""" if config["play"]["raw"]: return paths else: return [self._create_tmp_playlist(paths)] return [shlex.quote(self._create_tmp_playlist(paths))] def _exceeds_threshold( self, selection, command_str, open_args, item_type="track" ): """Prompt user whether to abort if playlist exceeds threshold. If True, cancel playback. If False, execute play command. """ warning_threshold = config["play"]["warning_threshold"].get(int) # Warn user before playing any huge playlists. if warning_threshold and len(selection) > warning_threshold: if len(selection) > 1: item_type += "s" ui.print_( colorize( "text_warning", f"You are about to queue {len(selection)} {item_type}.", ) ) if ui.input_options(("Continue", "Abort")) == "a": return True return False def _create_tmp_playlist(self, paths_list): """Create a temporary .m3u file. Return the filename.""" utf8_bom = config["play"]["bom"].get(bool) filename = get_temp_filename(__name__, suffix=".m3u") with open(filename, "wb") as m3u: if utf8_bom: m3u.write(b"\xef\xbb\xbf") for item in paths_list: m3u.write(item + b"\n") return filename def before_choose_candidate_listener(self, session, task): """Append a "Play" choice to the interactive importer prompt.""" return [PromptChoice("y", "plaY", self.importer_play)] def importer_play(self, session, task): """Get items from current import task and send to play function.""" selection = task.items paths = [item.path for item in selection] open_args = self._playlist_or_paths(paths) command_str = self._command_str() if not self._exceeds_threshold(selection, command_str, open_args): play( command_str, selection, paths, open_args, self._log, keep_open=True, ) ================================================ FILE: beetsplug/playlist.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import os import tempfile from pathlib import Path from typing import TYPE_CHECKING, ClassVar import beets from beets.dbcore.query import BLOB_TYPE, InQuery from beets.util import path_as_posix if TYPE_CHECKING: from collections.abc import Sequence from beets.dbcore.query import FieldQueryType def is_m3u_file(path: str) -> bool: return Path(path).suffix.lower() in {".m3u", ".m3u8"} class PlaylistQuery(InQuery[bytes]): """Matches files listed by a playlist file.""" @property def subvals(self) -> Sequence[BLOB_TYPE]: return [BLOB_TYPE(p) for p in self.pattern] def __init__(self, _, pattern: str, __): config = beets.config["playlist"] # Get the full path to the playlist playlist_paths = ( pattern, os.path.abspath( os.path.join( config["playlist_dir"].as_filename(), f"{pattern}.m3u", ) ), ) paths = [] for playlist_path in playlist_paths: if not is_m3u_file(playlist_path): # This is not am M3U playlist, skip this candidate continue try: f = open(beets.util.syspath(playlist_path), mode="rb") except OSError: continue if config["relative_to"].get() == "library": relative_to = beets.config["directory"].as_filename() elif config["relative_to"].get() == "playlist": relative_to = os.path.dirname(playlist_path) else: relative_to = config["relative_to"].as_filename() relative_to_bytes = beets.util.bytestring_path(relative_to) for line in f: if line[0] == "#": # ignore comments, and extm3u extension continue paths.append( beets.util.normpath( os.path.join(relative_to_bytes, line.rstrip()) ) ) f.close() break super().__init__("path", paths) class PlaylistPlugin(beets.plugins.BeetsPlugin): item_queries: ClassVar[dict[str, FieldQueryType]] = { "playlist": PlaylistQuery } def __init__(self): super().__init__() self.config.add( { "auto": False, "playlist_dir": ".", "relative_to": "library", "forward_slash": False, } ) self.playlist_dir = self.config["playlist_dir"].as_filename() self.changes = {} if self.config["relative_to"].get() == "library": self.relative_to = beets.util.bytestring_path( beets.config["directory"].as_filename() ) elif self.config["relative_to"].get() != "playlist": self.relative_to = beets.util.bytestring_path( self.config["relative_to"].as_filename() ) else: self.relative_to = None if self.config["auto"]: self.register_listener("item_moved", self.item_moved) self.register_listener("item_removed", self.item_removed) self.register_listener("cli_exit", self.cli_exit) def item_moved(self, item, source, destination): self.changes[source] = destination def item_removed(self, item): if not os.path.exists(beets.util.syspath(item.path)): self.changes[item.path] = None def cli_exit(self, lib): for playlist in self.find_playlists(): self._log.info("Updating playlist: {}", playlist) base_dir = beets.util.bytestring_path( self.relative_to if self.relative_to else os.path.dirname(playlist) ) try: self.update_playlist(playlist, base_dir) except beets.util.FilesystemError: self._log.error("Failed to update playlist: {}", playlist) def find_playlists(self): """Find M3U playlists in the playlist directory.""" playlist_dir = beets.util.syspath(self.playlist_dir) try: dir_contents = os.listdir(playlist_dir) except OSError: self._log.warning( "Unable to open playlist directory {.playlist_dir}", self ) return for filename in dir_contents: if is_m3u_file(filename): yield os.path.join(self.playlist_dir, filename) def update_playlist(self, filename, base_dir): """Find M3U playlists in the specified directory.""" changes = 0 deletions = 0 with tempfile.NamedTemporaryFile(mode="w+b", delete=False) as tempfp: new_playlist = tempfp.name with open(filename, mode="rb") as fp: for line in fp: original_path = line.rstrip(b"\r\n") # Ensure that path from playlist is absolute is_relative = not os.path.isabs(line) if is_relative: lookup = os.path.join(base_dir, original_path) else: lookup = original_path try: new_path = self.changes[beets.util.normpath(lookup)] except KeyError: if self.config["forward_slash"]: line = path_as_posix(line) tempfp.write(line) else: if new_path is None: # Item has been deleted deletions += 1 continue changes += 1 if is_relative: new_path = os.path.relpath(new_path, base_dir) line = line.replace(original_path, new_path) if self.config["forward_slash"]: line = path_as_posix(line) tempfp.write(line) if changes or deletions: self._log.info( "Updated playlist {} ({} changes, {} deletions)", filename, changes, deletions, ) beets.util.copy(new_playlist, filename, replace=True) beets.util.remove(new_playlist) ================================================ FILE: beetsplug/plexupdate.py ================================================ """Updates an Plex library whenever the beets library is changed. Plex Home users enter the Plex Token to enable updating. Put something like the following in your config.yaml to configure: plex: host: localhost port: 32400 token: token """ from urllib.parse import urlencode, urljoin from xml.etree import ElementTree import requests from beets import config from beets.plugins import BeetsPlugin def get_music_section( host, port, token, library_name, secure, ignore_cert_errors ): """Getting the section key for the music library in Plex.""" api_endpoint = append_token("library/sections", token) url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint) # Sends request. r = requests.get( url, verify=not ignore_cert_errors, timeout=10, ) # Parse xml tree and extract music section key. tree = ElementTree.fromstring(r.content) for child in tree.findall("Directory"): if child.get("title") == library_name: return child.get("key") def update_plex(host, port, token, library_name, secure, ignore_cert_errors): """Ignore certificate errors if configured to.""" if ignore_cert_errors: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) """Sends request to the Plex api to start a library refresh. """ # Getting section key and build url. section_key = get_music_section( host, port, token, library_name, secure, ignore_cert_errors ) api_endpoint = f"library/sections/{section_key}/refresh" api_endpoint = append_token(api_endpoint, token) url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint) # Sends request and returns requests object. r = requests.get( url, verify=not ignore_cert_errors, timeout=10, ) return r def append_token(url, token): """Appends the Plex Home token to the api call if required.""" if token: url += f"?{urlencode({'X-Plex-Token': token})}" return url def get_protocol(secure): if secure: return "https" else: return "http" class PlexUpdate(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. config["plex"].add( { "host": "localhost", "port": 32400, "token": "", "library_name": "Music", "secure": False, "ignore_cert_errors": False, } ) config["plex"]["token"].redact = True self.register_listener("database_change", self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update for the end""" self.register_listener("cli_exit", self.update) def update(self, lib): """When the client exists try to send refresh request to Plex server.""" self._log.info("Updating Plex library...") # Try to send update request. try: update_plex( config["plex"]["host"].get(), config["plex"]["port"].get(), config["plex"]["token"].get(), config["plex"]["library_name"].get(), config["plex"]["secure"].get(bool), config["plex"]["ignore_cert_errors"].get(bool), ) self._log.info("... started.") except requests.exceptions.RequestException: self._log.warning("Update failed.") ================================================ FILE: beetsplug/random.py ================================================ # This file is part of beets. # Copyright 2016, Philippe Mongeau. # Copyright 2025, Sebastian Mohr. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import random from itertools import groupby, islice from operator import methodcaller from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse from collections.abc import Iterable from beets.library import LibModel, Library def random_func(lib: Library, opts: optparse.Values, args: list[str]): """Select some random items or albums and print the results.""" # Fetch all the objects matching the query into a list. objs = lib.albums(args) if opts.album else lib.items(args) # Print a random subset. for obj in random_objs( objs=objs, equal_chance_field=opts.field, number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, ): print_(format(obj)) random_cmd = Subcommand("random", help="choose a random track or album") random_cmd.parser.add_option( "-n", "--number", action="store", type="int", help="number of objects to choose", default=1, ) random_cmd.parser.add_option( "-e", "--equal-chance", action="store_true", help="each field has the same chance", ) random_cmd.parser.add_option( "-t", "--time", action="store", type="float", help="total length in minutes of objects to choose", ) random_cmd.parser.add_option( "--field", action="store", type="string", default="albumartist", help="field to use for equal chance sampling (default: albumartist)", ) random_cmd.parser.add_all_common_options() random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] def _equal_chance_permutation( objs: Iterable[LibModel], field: str ) -> Iterable[LibModel]: """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. """ # Group the objects by field so we can sample from them. get_attr = methodcaller("get", field) groups = {} for k, values in groupby(sorted(objs, key=get_attr), key=get_attr): if k is not None: vals = list(values) # shuffle in category random.shuffle(vals) groups[str(k)] = vals while groups: group = random.choice(list(groups.keys())) yield groups[group].pop() if not groups[group]: del groups[group] def _take_time( iter: Iterable[LibModel], secs: float, ) -> Iterable[LibModel]: """Return a list containing the first values in `iter`, which should be Item or Album objects, that add up to the given amount of time in seconds. """ total_time = 0.0 for obj in iter: length = obj.length if total_time + length <= secs: yield obj total_time += length def random_objs( objs: Iterable[LibModel], equal_chance_field: str, number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, ) -> Iterable[LibModel]: """Get a random subset of items, optionally constrained by time or count. Args: - objs: The sequence of objects to choose from. - number: The number of objects to select. - time_minutes: If specified, the total length of selected objects should not exceed this many minutes. - equal_chance: If True, each field has the same chance of being selected, regardless of how many tracks they have. - random_gen: An optional random generator to use for shuffling. """ # Permute the objects either in a straightforward way or an # field-balanced way. if equal_chance: perm = _equal_chance_permutation(objs, equal_chance_field) else: perm = list(objs) random.shuffle(perm) # Select objects by time our count. if time_minutes: return _take_time(perm, time_minutes * 60) else: return islice(perm, number) ================================================ FILE: beetsplug/replace.py ================================================ from __future__ import annotations import shutil from pathlib import Path from typing import TYPE_CHECKING import mediafile from beets import ui, util from beets.plugins import BeetsPlugin if TYPE_CHECKING: from beets.library import Item, Library class ReplacePlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand( "replace", help="replace audio file while keeping tags" ) cmd.func = self.run return [cmd] def run(self, lib: Library, args: list[str]) -> None: if len(args) < 2: raise ui.UserError("Usage: beet replace <query> <new_file_path>") new_file_path: Path = Path(args[-1]) item_query: list[str] = args[:-1] self.file_check(new_file_path) item_list = list(lib.items(item_query)) if not item_list: raise ui.UserError("No matching songs found.") song = self.select_song(item_list) if not song: ui.print_("Operation cancelled.") return if not self.confirm_replacement(new_file_path, song): ui.print_("Aborting replacement.") return self.replace_file(new_file_path, song) def file_check(self, filepath: Path) -> None: """Check if the file exists and is supported""" if not filepath.is_file(): raise ui.UserError( f"'{util.displayable_path(filepath)}' is not a valid file." ) try: mediafile.MediaFile(util.syspath(filepath)) except mediafile.FileTypeError as fte: raise ui.UserError(fte) def select_song(self, items: list[Item]): """Present a menu of matching songs and get user selection.""" ui.print_("\nMatching songs:") for i, item in enumerate(items, 1): ui.print_(f"{i}. {util.displayable_path(item)}") while True: try: index = int( input( f"Which song would you like to replace? " f"[1-{len(items)}] (0 to cancel): " ) ) if index == 0: return None if 1 <= index <= len(items): return items[index - 1] ui.print_( f"Invalid choice. Please enter a number " f"between 1 and {len(items)}." ) except ValueError: ui.print_("Invalid input. Please type in a number.") def confirm_replacement(self, new_file_path: Path, song: Item): """Get user confirmation for the replacement.""" original_file_path: Path = Path(song.path.decode()) if not original_file_path.exists(): raise ui.UserError("The original song file was not found.") ui.print_( f"\nReplacing: {util.displayable_path(new_file_path)} " f"-> {util.displayable_path(original_file_path)}" ) decision: str = ( input("Are you sure you want to replace this track? (y/N): ") .strip() .casefold() ) return decision in {"yes", "y"} def replace_file(self, new_file_path: Path, song: Item) -> None: """Replace the existing file with the new one.""" original_file_path = Path(song.path.decode()) dest = original_file_path.with_suffix(new_file_path.suffix) try: shutil.move(util.syspath(new_file_path), util.syspath(dest)) except Exception as e: raise ui.UserError(f"Error replacing file: {e}") if ( new_file_path.suffix != original_file_path.suffix and original_file_path.exists() ): try: original_file_path.unlink() except Exception as e: raise ui.UserError(f"Could not delete original file: {e}") song.path = str(dest).encode() song.store() ui.print_("Replacement successful.") ================================================ FILE: beetsplug/replaygain.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import collections import enum import math import os import queue import shutil import signal import subprocess import sys import warnings from abc import ABC, abstractmethod from dataclasses import dataclass from multiprocessing.pool import ThreadPool from pathlib import Path from threading import Event, Thread from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar from beets import ui from beets.plugins import BeetsPlugin from beets.util import command_output, syspath if TYPE_CHECKING: import optparse from collections.abc import Callable, Sequence from logging import Logger from confuse import ConfigView from beets.importer import ImportSession, ImportTask from beets.library import Album, Item, Library # Utilities. class ReplayGainError(Exception): """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): """Raised when a fatal error occurs in one of the backends.""" class FatalGstreamerPluginReplayGainError(FatalReplayGainError): """Raised when a fatal error occurs in the GStreamerBackend when loading the required plugins.""" def call(args: list[str], log: Logger, **kwargs: Any): """Execute the command and return its output or raise a ReplayGainError on failure. """ try: return command_output(args, **kwargs) except subprocess.CalledProcessError as e: log.debug(e.output.decode("utf8", "ignore")) raise ReplayGainError(f"{args[0]} exited with status {e.returncode}") def db_to_lufs(db: float) -> float: """Convert db to LUFS. According to https://wiki.hydrogenaud.io/index.php?title= ReplayGain_2.0_specification#Reference_level """ return db - 107 def lufs_to_db(db: float) -> float: """Convert LUFS to db. According to https://wiki.hydrogenaud.io/index.php?title= ReplayGain_2.0_specification#Reference_level """ return db + 107 # Backend base and plumbing classes. @dataclass class Gain: # gain: in LU to reference level gain: float # peak: part of full scale (FS is 1.0) peak: float class PeakMethod(enum.Enum): true = 1 sample = 2 class RgTask: """State and methods for a single replaygain calculation (rg version). Bundles the state (parameters and results) of a single replaygain calculation (either for one item, one disk, or one full album). This class provides methods to store the resulting gains and peaks as plain old rg tags. """ def __init__( self, items: Sequence[Item], album: Album | None, target_level: float, peak_method: PeakMethod | None, backend_name: str, log: Logger, ): self.items = items self.album = album self.target_level = target_level self.peak_method = peak_method self.backend_name = backend_name self._log = log self.album_gain: Gain | None = None self.track_gains: list[Gain] | None = None def _store_track_gain(self, item: Item, track_gain: Gain): """Store track gain for a single item in the database.""" item.rg_track_gain = track_gain.gain item.rg_track_peak = track_gain.peak item.store() self._log.debug( "applied track gain {0.rg_track_gain} LU, peak {0.rg_track_peak} of FS", item, ) def _store_album_gain(self, item: Item, album_gain: Gain): """Store album gain for a single item in the database. The caller needs to ensure that `self.album_gain is not None`. """ item.rg_album_gain = album_gain.gain item.rg_album_peak = album_gain.peak item.store() self._log.debug( "applied album gain {0.rg_album_gain} LU, peak {0.rg_album_peak} of FS", item, ) def _store_track(self, write: bool): """Store track gain for the first track of the task in the database.""" item = self.items[0] if self.track_gains is None or len(self.track_gains) != 1: # In some cases, backends fail to produce a valid # `track_gains` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( f"ReplayGain backend `{self.backend_name}` failed for track" f" {item}" ) self._store_track_gain(item, self.track_gains[0]) if write: item.try_write() self._log.debug("done analyzing {}", item) def _store_album(self, write: bool): """Store track/album gains for all tracks of the task in the database.""" if ( self.album_gain is None or self.track_gains is None or len(self.track_gains) != len(self.items) ): # In some cases, backends fail to produce a valid # `album_gain` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( f"ReplayGain backend `{self.backend_name}` failed " f"for some tracks in album {self.album}" ) for item, track_gain in zip(self.items, self.track_gains): self._store_track_gain(item, track_gain) self._store_album_gain(item, self.album_gain) if write: item.try_write() self._log.debug("done analyzing {}", item) def store(self, write: bool): """Store computed gains for the items of this task in the database.""" if self.album is not None: self._store_album(write) else: self._store_track(write) class R128Task(RgTask): """State and methods for a single replaygain calculation (r128 version). Bundles the state (parameters and results) of a single replaygain calculation (either for one item, one disk, or one full album). This class provides methods to store the resulting gains and peaks as R128 tags. """ def __init__( self, items: Sequence[Item], album: Album | None, target_level: float, backend_name: str, log: Logger, ): # R128_* tags do not store the track/album peak super().__init__(items, album, target_level, None, backend_name, log) def _store_track_gain(self, item: Item, track_gain: Gain): item.r128_track_gain = track_gain.gain item.store() self._log.debug("applied r128 track gain {.r128_track_gain} LU", item) def _store_album_gain(self, item: Item, album_gain: Gain): """ The caller needs to ensure that `self.album_gain is not None`. """ item.r128_album_gain = album_gain.gain item.store() self._log.debug("applied r128 album gain {.r128_album_gain} LU", item) AnyRgTask = TypeVar("AnyRgTask", bound=RgTask) class Backend(ABC): """An abstract class representing engine for calculating RG values.""" NAME = "" do_parallel = False def __init__(self, config: ConfigView, log: Logger): """Initialize the backend with the configuration view for the plugin. """ self._log = log @abstractmethod def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the track gain for the tracks belonging to `task`, and sets the `track_gains` attribute on the task. Returns `task`. """ raise NotImplementedError() @abstractmethod def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the album gain for the album belonging to `task`, and sets the `album_gain` attribute on the task. Returns `task`. """ raise NotImplementedError() # ffmpeg backend class FfmpegBackend(Backend): """A replaygain backend using ffmpeg's ebur128 filter.""" NAME = "ffmpeg" do_parallel = True def __init__(self, config: ConfigView, log: Logger): super().__init__(config, log) self._ffmpeg_path = "ffmpeg" # check that ffmpeg is installed try: ffmpeg_version_out = call([self._ffmpeg_path, "-version"], log) except OSError: raise FatalReplayGainError( f"could not find ffmpeg at {self._ffmpeg_path}" ) incompatible_ffmpeg = True for line in ffmpeg_version_out.stdout.splitlines(): if line.startswith(b"configuration:"): if b"--enable-libebur128" in line: incompatible_ffmpeg = False if line.startswith(b"libavfilter"): version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".") version = tuple(map(int, version)) if version >= (6, 67, 100): incompatible_ffmpeg = False if incompatible_ffmpeg: raise FatalReplayGainError( "Installed FFmpeg version does not support ReplayGain." "calculation. Either libavfilter version 6.67.100 or above or" "the --enable-libebur128 configuration option is required." ) def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the track gain for the tracks belonging to `task`, and sets the `track_gains` attribute on the task. Returns `task`. """ task.track_gains = [ self._analyse_item( item, task.target_level, task.peak_method, count_blocks=False, )[0] # take only the gain, discarding number of gating blocks for item in task.items ] return task def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the album gain for the album belonging to `task`, and sets the `album_gain` attribute on the task. Returns `task`. """ target_level_lufs = db_to_lufs(task.target_level) # analyse tracks # Gives a list of tuples (track_gain, track_n_blocks) track_results: list[tuple[Gain, int]] = [ self._analyse_item( item, task.target_level, task.peak_method, count_blocks=True, ) for item in task.items ] track_gains: list[Gain] = [tg for tg, _nb in track_results] # Album peak is maximum track peak album_peak = max(tg.peak for tg in track_gains) # Total number of BS.1770 gating blocks n_blocks = sum(nb for _tg, nb in track_results) def sum_of_track_powers(track_gain: Gain, track_n_blocks: int): # convert `LU to target_level` -> LUFS loudness = target_level_lufs - track_gain.gain # This reverses ITU-R BS.1770-4 p. 6 equation (5) to convert # from loudness to power. The result is the average gating # block power. power = 10 ** ((loudness + 0.691) / 10) # Multiply that average power by the number of gating blocks to get # the sum of all block powers in this track. return track_n_blocks * power # calculate album gain if n_blocks > 0: # Sum over all tracks to get the sum of BS.1770 gating block powers # for the entire album. sum_powers = sum( sum_of_track_powers(tg, nb) for tg, nb in track_results ) # compare ITU-R BS.1770-4 p. 6 equation (5) # Album gain is the replaygain of the concatenation of all tracks. album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks) else: album_gain = -70 # convert LUFS -> `LU to target_level` album_gain = target_level_lufs - album_gain self._log.debug( "{.album}: gain {} LU, peak {}", task, album_gain, album_peak ) task.album_gain = Gain(album_gain, album_peak) task.track_gains = track_gains return task def _construct_cmd( self, item: Item, peak_method: PeakMethod | None ) -> list[str]: """Construct the shell command to analyse items.""" return [ self._ffmpeg_path, "-nostats", "-hide_banner", "-i", str(item.filepath), "-map", "a:0", "-filter", f"ebur128=peak={'none' if peak_method is None else peak_method.name}", "-f", "null", "-", ] def _analyse_item( self, item: Item, target_level: float, peak_method: PeakMethod | None, count_blocks: bool = True, ) -> tuple[Gain, int]: """Analyse item. Return a pair of a Gain object and the number of gating blocks above the threshold. If `count_blocks` is False, the number of gating blocks returned will be 0. """ target_level_lufs = db_to_lufs(target_level) # call ffmpeg self._log.debug("analyzing {}", item) cmd = self._construct_cmd(item, peak_method) self._log.debug("executing {}", " ".join(cmd)) output = call(cmd, self._log).stderr.splitlines() # parse output if peak_method is None: peak = 0.0 else: line_peak = self._find_line( output, # `peak_method` is non-`None` in this arm of the conditional f" {peak_method.name.capitalize()} peak:".encode(), start_line=len(output) - 1, step_size=-1, ) peak = self._parse_float( output[ self._find_line( output, b" Peak:", line_peak, ) ] ) # convert TPFS -> part of FS peak = 10 ** (peak / 20) line_integrated_loudness = self._find_line( output, b" Integrated loudness:", start_line=len(output) - 1, step_size=-1, ) gain = self._parse_float( output[ self._find_line( output, b" I:", line_integrated_loudness, ) ] ) # convert LUFS -> LU from target level gain = target_level_lufs - gain # count BS.1770 gating blocks n_blocks = 0 if count_blocks: gating_threshold = self._parse_float( output[ self._find_line( output, b" Threshold:", start_line=line_integrated_loudness, ) ] ) for line in output: if not line.startswith(b"[Parsed_ebur128"): continue if line.endswith(b"Summary:"): continue line = line.split(b"M:", 1) if len(line) < 2: continue if self._parse_float(b"M: " + line[1]) >= gating_threshold: n_blocks += 1 self._log.debug( "{}: {} blocks over {} LUFS", item, n_blocks, gating_threshold ) self._log.debug("{}: gain {} LU, peak {}", item, gain, peak) return Gain(gain, peak), n_blocks def _find_line( self, output: Sequence[bytes], search: bytes, start_line: int = 0, step_size: int = 1, ) -> int: """Return index of line beginning with `search`. Begins searching at index `start_line` in `output`. """ end_index = len(output) if step_size > 0 else -1 for i in range(start_line, end_index, step_size): if output[i].startswith(search): return i raise ReplayGainError( f"ffmpeg output: missing {search!r} after line {start_line}" ) def _parse_float(self, line: bytes) -> float: """Extract a float from a key value pair in `line`. This format is expected: /[^:]:[[:space:]]*value.*/, where `value` is the float. """ # extract value parts = line.split(b":", 1) if len(parts) < 2: raise ReplayGainError( f"ffmpeg output: expected key value pair, found {line!r}" ) value = parts[1].lstrip() # strip unit value = value.split(b" ", 1)[0] # cast value to float try: return float(value) except ValueError: raise ReplayGainError( f"ffmpeg output: expected float value, found {value!r}" ) # mpgain/aacgain CLI tool backend. Tool = Literal["mp3rgain", "aacgain", "mp3gain"] class CommandBackend(Backend): NAME = "command" SUPPORTED_FORMATS_BY_TOOL: ClassVar[dict[Tool, set[str]]] = { "mp3rgain": {"AAC", "MP3"}, "aacgain": {"AAC", "MP3"}, "mp3gain": {"MP3"}, } do_parallel = True cmd_name: Tool def __init__(self, config: ConfigView, log: Logger): super().__init__(config, log) config.add( { "command": "", "noclip": True, } ) cmd_path: Path = Path(config["command"].as_str()) supported_tools = set(self.SUPPORTED_FORMATS_BY_TOOL) if (cmd_name := cmd_path.name) not in supported_tools: raise FatalReplayGainError( f"replaygain.command must be one of {supported_tools!r}," f" not {cmd_name!r}" ) if command_exec := shutil.which(str(cmd_path)): self.command = command_exec self.cmd_name = cmd_name # type: ignore[assignment] else: raise FatalReplayGainError( f"replaygain command not found: {cmd_path}" ) self.noclip = config["noclip"].get(bool) def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the track gain for the tracks belonging to `task`, and sets the `track_gains` attribute on the task. Returns `task`. """ supported_items = list(filter(self.format_supported, task.items)) output = self.compute_gain(supported_items, task.target_level, False) task.track_gains = output return task def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the album gain for the album belonging to `task`, and sets the `album_gain` attribute on the task. Returns `task`. """ # TODO: What should be done when not all tracks in the album are # supported? supported_items = list(filter(self.format_supported, task.items)) if len(supported_items) != len(task.items): self._log.debug("tracks are of unsupported format") task.album_gain = None task.track_gains = None return task output = self.compute_gain(supported_items, task.target_level, True) task.album_gain = output[-1] task.track_gains = output[:-1] return task def format_supported(self, item: Item) -> bool: """Checks whether the given item is supported by the selected tool.""" return item.format in self.SUPPORTED_FORMATS_BY_TOOL[self.cmd_name] def compute_gain( self, items: Sequence[Item], target_level: float, is_album: bool, ) -> list[Gain]: """Computes the track or album gain of a list of items, returns a list of TrackGain objects. When computing album gain, the last TrackGain object returned is the album gain """ if not items: self._log.debug("no supported tracks to analyze") return [] """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ # Construct shell command. The "-o" option makes the output # easily parseable (tab-delimited). "-s s" forces gain # recalculation even if tags are already present and disables # tag-writing; this turns the mp3gain/aacgain tool into a gain # calculator rather than a tag manipulator because we take care # of changing tags ourselves. cmd = [ self.command, "-o", "-s", "s", # Avoid clipping or disable clipping warning "-k" if self.noclip else "-c", "-d", str(int(target_level - 89)), *[str(i.filepath) for i in items], ] self._log.debug("analyzing {} files", len(items)) self._log.debug("executing {}", " ".join(cmd)) output = call(cmd, self._log).stdout self._log.debug("analysis finished") return self.parse_tool_output( output, len(items) + (1 if is_album else 0) ) def parse_tool_output(self, text: bytes, num_lines: int) -> list[Gain]: """Given the tab-delimited output from an invocation of mp3gain or aacgain, parse the text and return a list of dictionaries containing information about each analyzed file. """ out = [] for line in text.split(b"\n")[1 : num_lines + 1]: parts = line.split(b"\t") if len(parts) != 6 or parts[0] == b"File": self._log.debug("bad tool output: {}", text) raise ReplayGainError("mp3gain failed") # _file = parts[0] # _mp3gain = int(parts[1]) gain = float(parts[2]) peak = float(parts[3]) / (1 << 15) # _maxgain = int(parts[4]) # _mingain = int(parts[5]) out.append(Gain(gain, peak)) return out # GStreamer-based backend. class GStreamerBackend(Backend): NAME = "gstreamer" def __init__(self, config: ConfigView, log: Logger): super().__init__(config, log) self._import_gst() # Initialized a GStreamer pipeline of the form filesrc -> # decodebin -> audioconvert -> audioresample -> rganalysis -> # fakesink The connection between decodebin and audioconvert is # handled dynamically after decodebin figures out the type of # the input file. self._src = self.Gst.ElementFactory.make("filesrc", "src") self._decbin = self.Gst.ElementFactory.make("decodebin", "decbin") self._conv = self.Gst.ElementFactory.make("audioconvert", "conv") self._res = self.Gst.ElementFactory.make("audioresample", "res") self._rg = self.Gst.ElementFactory.make("rganalysis", "rg") if ( self._src is None or self._decbin is None or self._conv is None or self._res is None or self._rg is None ): raise FatalGstreamerPluginReplayGainError( "Failed to load required GStreamer plugins" ) # We check which files need gain ourselves, so all files given # to rganalsys should have their gain computed, even if it # already exists. self._rg.set_property("forced", True) self._sink = self.Gst.ElementFactory.make("fakesink", "sink") self._pipe = self.Gst.Pipeline() self._pipe.add(self._src) self._pipe.add(self._decbin) self._pipe.add(self._conv) self._pipe.add(self._res) self._pipe.add(self._rg) self._pipe.add(self._sink) self._src.link(self._decbin) self._conv.link(self._res) self._res.link(self._rg) self._rg.link(self._sink) self._bus = self._pipe.get_bus() self._bus.add_signal_watch() self._bus.connect("message::eos", self._on_eos) self._bus.connect("message::error", self._on_error) self._bus.connect("message::tag", self._on_tag) # Needed for handling the dynamic connection between decodebin # and audioconvert self._decbin.connect("pad-added", self._on_pad_added) self._decbin.connect("pad-removed", self._on_pad_removed) self._main_loop = self.GLib.MainLoop() self._files: list[bytes] = [] def _import_gst(self): """Import the necessary GObject-related modules and assign `Gst` and `GObject` fields on this object. """ try: import gi except ImportError: raise FatalReplayGainError( "Failed to load GStreamer: python-gi not found" ) try: gi.require_version("Gst", "1.0") except ValueError as e: raise FatalReplayGainError(f"Failed to load GStreamer 1.0: {e}") from gi.repository import GLib, GObject, Gst # Calling GObject.threads_init() is not needed for # PyGObject 3.10.2+ with warnings.catch_warnings(): warnings.simplefilter("ignore") GObject.threads_init() Gst.init([sys.argv[0]]) self.GObject = GObject self.GLib = GLib self.Gst = Gst def compute(self, items: Sequence[Item], target_level: float, album: bool): if len(items) == 0: return self._error = None self._files = [i.path for i in items] # FIXME: Turn this into DefaultDict[bytes, Gain] self._file_tags: collections.defaultdict[bytes, dict[str, float]] = ( collections.defaultdict(dict) ) self._rg.set_property("reference-level", target_level) if album: self._rg.set_property("num-tracks", len(self._files)) if self._set_first_file(): self._main_loop.run() if self._error is not None: raise self._error def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the track gain for the tracks belonging to `task`, and sets the `track_gains` attribute on the task. Returns `task`. """ self.compute(task.items, task.target_level, False) if len(self._file_tags) != len(task.items): raise ReplayGainError("Some tracks did not receive tags") ret = [] for item in task.items: ret.append( Gain( self._file_tags[item.path]["TRACK_GAIN"], self._file_tags[item.path]["TRACK_PEAK"], ) ) task.track_gains = ret return task def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the album gain for the album belonging to `task`, and sets the `album_gain` attribute on the task. Returns `task`. """ items = list(task.items) self.compute(items, task.target_level, True) if len(self._file_tags) != len(items): raise ReplayGainError("Some items in album did not receive tags") # Collect track gains. track_gains = [] for item in items: try: gain = self._file_tags[item.path]["TRACK_GAIN"] peak = self._file_tags[item.path]["TRACK_PEAK"] except KeyError: raise ReplayGainError("results missing for track") track_gains.append(Gain(gain, peak)) # Get album gain information from the last track. last_tags = self._file_tags[items[-1].path] try: gain = last_tags["ALBUM_GAIN"] peak = last_tags["ALBUM_PEAK"] except KeyError: raise ReplayGainError("results missing for album") task.album_gain = Gain(gain, peak) task.track_gains = track_gains return task def close(self): self._bus.remove_signal_watch() def _on_eos(self, bus, message): # A file finished playing in all elements of the pipeline. The # RG tags have already been propagated. If we don't have a next # file, we stop processing. if not self._set_next_file(): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() def _on_error(self, bus, message): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() err, debug = message.parse_error() f = self._src.get_property("location") # A GStreamer error, either an unsupported format or a bug. self._error = ReplayGainError( f"Error {err!r} - {debug!r} on file {f!r}" ) def _on_tag(self, bus, message): tags = message.parse_tag() def handle_tag(taglist, tag, userdata): # The rganalysis element provides both the existing tags for # files and the new computes tags. In order to ensure we # store the computed tags, we overwrite the RG values of # received a second time. if tag == self.Gst.TAG_TRACK_GAIN: self._file_tags[self._file]["TRACK_GAIN"] = taglist.get_double( tag )[1] elif tag == self.Gst.TAG_TRACK_PEAK: self._file_tags[self._file]["TRACK_PEAK"] = taglist.get_double( tag )[1] elif tag == self.Gst.TAG_ALBUM_GAIN: self._file_tags[self._file]["ALBUM_GAIN"] = taglist.get_double( tag )[1] elif tag == self.Gst.TAG_ALBUM_PEAK: self._file_tags[self._file]["ALBUM_PEAK"] = taglist.get_double( tag )[1] elif tag == self.Gst.TAG_REFERENCE_LEVEL: self._file_tags[self._file]["REFERENCE_LEVEL"] = ( taglist.get_double(tag)[1] ) tags.foreach(handle_tag, None) def _set_first_file(self) -> bool: if len(self._files) == 0: return False self._file = self._files.pop(0) self._pipe.set_state(self.Gst.State.NULL) self._src.set_property("location", os.fsdecode(syspath(self._file))) self._pipe.set_state(self.Gst.State.PLAYING) return True def _set_file(self) -> bool: """Initialize the filesrc element with the next file to be analyzed.""" # No more files, we're done if len(self._files) == 0: return False self._file = self._files.pop(0) # Ensure the filesrc element received the paused state of the # pipeline in a blocking manner self._src.sync_state_with_parent() self._src.get_state(self.Gst.CLOCK_TIME_NONE) # Ensure the decodebin element receives the paused state of the # pipeline in a blocking manner self._decbin.sync_state_with_parent() self._decbin.get_state(self.Gst.CLOCK_TIME_NONE) # Disconnect the decodebin element from the pipeline, set its # state to READY to to clear it. self._decbin.unlink(self._conv) self._decbin.set_state(self.Gst.State.READY) # Set a new file on the filesrc element, can only be done in the # READY state self._src.set_state(self.Gst.State.READY) self._src.set_property("location", os.fsdecode(syspath(self._file))) self._decbin.link(self._conv) self._pipe.set_state(self.Gst.State.READY) return True def _set_next_file(self) -> bool: """Set the next file to be analyzed while keeping the pipeline in the PAUSED state so that the rganalysis element can correctly handle album gain. """ # A blocking pause self._pipe.set_state(self.Gst.State.PAUSED) self._pipe.get_state(self.Gst.CLOCK_TIME_NONE) # Try setting the next file ret = self._set_file() if ret: # Seek to the beginning in order to clear the EOS state of the # various elements of the pipeline self._pipe.seek_simple( self.Gst.Format.TIME, self.Gst.SeekFlags.FLUSH, 0 ) self._pipe.set_state(self.Gst.State.PLAYING) return ret def _on_pad_added(self, decbin, pad): sink_pad = self._conv.get_compatible_pad(pad, None) assert sink_pad is not None pad.link(sink_pad) def _on_pad_removed(self, decbin, pad): # Called when the decodebin element is disconnected from the # rest of the pipeline while switching input files peer = pad.get_peer() assert peer is None class AudioToolsBackend(Backend): """ReplayGain backend that uses `Python Audio Tools <http://audiotools.sourceforge.net/>`_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ NAME = "audiotools" def __init__(self, config: ConfigView, log: Logger): super().__init__(config, log) self._import_audiotools() def _import_audiotools(self): """Check whether it's possible to import the necessary modules. There is no check on the file formats at runtime. :raises :exc:`ReplayGainError`: if the modules cannot be imported """ try: import audiotools import audiotools.replaygain except ImportError: raise FatalReplayGainError( "Failed to load audiotools: audiotools not found" ) self._mod_audiotools = audiotools self._mod_replaygain = audiotools.replaygain def open_audio_file(self, item: Item): """Open the file to read the PCM stream from the using ``item.path``. :return: the audiofile instance :rtype: :class:`audiotools.AudioFile` :raises :exc:`ReplayGainError`: if the file is not found or the file format is not supported """ try: audiofile = self._mod_audiotools.open( os.fsdecode(syspath(item.path)) ) except OSError: raise ReplayGainError(f"File {item.filepath} was not found") except self._mod_audiotools.UnsupportedFile: raise ReplayGainError(f"Unsupported file type {item.format}") return audiofile def init_replaygain(self, audiofile, item: Item): """Return an initialized :class:`audiotools.replaygain.ReplayGain` instance, which requires the sample rate of the song(s) on which the ReplayGain values will be computed. The item is passed in case the sample rate is invalid to log the stored item sample rate. :return: initialized replagain object :rtype: :class:`audiotools.replaygain.ReplayGain` :raises: :exc:`ReplayGainError` if the sample rate is invalid """ try: rg = self._mod_replaygain.ReplayGain(audiofile.sample_rate()) except ValueError: raise ReplayGainError(f"Unsupported sample rate {item.samplerate}") return return rg def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the track gain for the tracks belonging to `task`, and sets the `track_gains` attribute on the task. Returns `task`. """ gains = [ self._compute_track_gain(i, task.target_level) for i in task.items ] task.track_gains = gains return task def _with_target_level(self, gain: float, target_level: float): """Return `gain` relative to `target_level`. Assumes `gain` is relative to 89 db. """ return gain + (target_level - 89) def _title_gain(self, rg, audiofile, target_level: float): """Get the gain result pair from PyAudioTools using the `ReplayGain` instance `rg` for the given `audiofile`. Wraps `rg.title_gain(audiofile.to_pcm())` and throws a `ReplayGainError` when the library fails. """ try: # The method needs an audiotools.PCMReader instance that can # be obtained from an audiofile instance. gain, peak = rg.title_gain(audiofile.to_pcm()) except ValueError as exc: # `audiotools.replaygain` can raise a `ValueError` if the sample # rate is incorrect. self._log.debug("error in rg.title_gain() call: {}", exc) raise ReplayGainError("audiotools audio data error") return self._with_target_level(gain, target_level), peak def _compute_track_gain(self, item: Item, target_level: float): """Compute ReplayGain value for the requested item. :rtype: :class:`Gain` """ audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) # Each call to title_gain on a ReplayGain object returns peak and gain # of the track. rg_track_gain, rg_track_peak = self._title_gain( rg, audiofile, target_level ) self._log.debug( "ReplayGain for track {0.artist} - {0.title}: {1:.2f}, {2:.2f}", item, rg_track_gain, rg_track_peak, ) return Gain(gain=rg_track_gain, peak=rg_track_peak) def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: """Computes the album gain for the album belonging to `task`, and sets the `album_gain` attribute on the task. Returns `task`. """ # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the # tracks in the album to get the album values. item = next(iter(task.items)) audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) track_gains = [] for item in task.items: audiofile = self.open_audio_file(item) rg_track_gain, rg_track_peak = self._title_gain( rg, audiofile, task.target_level ) track_gains.append(Gain(gain=rg_track_gain, peak=rg_track_peak)) self._log.debug( "ReplayGain for track {}: {.2f}, {.2f}", item, rg_track_gain, rg_track_peak, ) # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() rg_album_gain = self._with_target_level( rg_album_gain, task.target_level ) self._log.debug( "ReplayGain for album {.items[0].album}: {.2f}, {.2f}", task, rg_album_gain, rg_album_peak, ) task.album_gain = Gain(gain=rg_album_gain, peak=rg_album_peak) task.track_gains = track_gains return task class ExceptionWatcher(Thread): """Monitors a queue for exceptions asynchronously. Once an exception occurs, raise it and execute a callback. """ def __init__( self, queue: queue.Queue[Exception], callback: Callable[[], None] ): self._queue = queue self._callback = callback self._stopevent = Event() Thread.__init__(self) def run(self): while not self._stopevent.is_set(): try: exc = self._queue.get_nowait() self._callback() raise exc except queue.Empty: # No exceptions yet, loop back to check # whether `_stopevent` is set pass def join(self, timeout: float | None = None): self._stopevent.set() Thread.join(self, timeout) # Main plugin logic. BACKEND_CLASSES: list[type[Backend]] = [ CommandBackend, GStreamerBackend, AudioToolsBackend, FfmpegBackend, ] BACKENDS: dict[str, type[Backend]] = {b.NAME: b for b in BACKEND_CLASSES} class ReplayGainPlugin(BeetsPlugin): """Provides ReplayGain analysis.""" pool: ThreadPool | None = None def __init__(self) -> None: super().__init__() # default backend is 'command' for backward-compatibility. self.config.add( { "overwrite": False, "auto": True, "backend": "command", "threads": os.cpu_count(), "parallel_on_import": False, "per_disc": False, "peak": "true", "targetlevel": 89, "r128": ["Opus"], "r128_targetlevel": lufs_to_db(-23), } ) # FIXME: Consider renaming the configuration option and deprecating the # old name 'overwrite'. self.force_on_import: bool = self.config["overwrite"].get(bool) # Remember which backend is used for CLI feedback self.backend_name = self.config["backend"].as_str() if self.backend_name not in BACKENDS: raise ui.UserError( f"Selected ReplayGain backend {self.backend_name} is not" f" supported. Please select one of: {', '.join(BACKENDS)}" ) # FIXME: Consider renaming the configuration option to 'peak_method' # and deprecating the old name 'peak'. peak_method = self.config["peak"].as_str() if peak_method not in PeakMethod.__members__: raise ui.UserError( f"Selected ReplayGain peak method {peak_method} is not" " supported. Please select one of:" f" {', '.join(PeakMethod.__members__)}" ) # This only applies to plain old rg tags, r128 doesn't store peak # values. self.peak_method = PeakMethod[peak_method] # On-import analysis. if self.config["auto"]: self.register_listener("import_begin", self.import_begin) self.register_listener("import", self.import_end) self.import_stages = [self.imported] # Formats to use R128. self.r128_whitelist = self.config["r128"].as_str_seq() try: self.backend_instance = BACKENDS[self.backend_name]( self.config, self._log ) except (ReplayGainError, FatalReplayGainError) as e: raise ui.UserError(f"replaygain initialization failed: {e}") def should_use_r128(self, item: Item) -> bool: """Checks the plugin setting to decide whether the calculation should be done using the EBU R128 standard and use R128_ tags instead. """ return item.format in self.r128_whitelist @staticmethod def has_r128_track_data(item: Item) -> bool: return item.r128_track_gain is not None @staticmethod def has_rg_track_data(item: Item) -> bool: return item.rg_track_gain is not None and item.rg_track_peak is not None def track_requires_gain(self, item: Item) -> bool: if self.should_use_r128(item): if not self.has_r128_track_data(item): return True else: if not self.has_rg_track_data(item): return True return False @staticmethod def has_r128_album_data(item: Item) -> bool: return ( item.r128_track_gain is not None and item.r128_album_gain is not None ) @staticmethod def has_rg_album_data(item: Item) -> bool: return item.rg_album_gain is not None and item.rg_album_peak is not None def album_requires_gain(self, album: Album) -> bool: # Skip calculating gain only when *all* files don't need # recalculation. This way, if any file among an album's tracks # needs recalculation, we still get an accurate album gain # value. for item in album.items(): if self.should_use_r128(item): if not self.has_r128_album_data(item): return True else: if not self.has_rg_album_data(item): return True return False def create_task( self, items: Sequence[Item], use_r128: bool, album: Album | None = None, ) -> RgTask: if use_r128: return R128Task( items, album, self.config["r128_targetlevel"].as_number(), self.backend_instance.NAME, self._log, ) else: return RgTask( items, album, self.config["targetlevel"].as_number(), self.peak_method, self.backend_instance.NAME, self._log, ) def handle_album(self, album: Album, write: bool, force: bool = False): """Compute album and track replay gain store it in all of the album's items. If ``write`` is truthy then ``item.write()`` is called for each item. If replay gain information is already present in all items, nothing is done. """ if not force and not self.album_requires_gain(album): self._log.info("Skipping album {}", album) return items_iter = iter(album.items()) use_r128 = self.should_use_r128(next(items_iter)) if any(use_r128 != self.should_use_r128(i) for i in items_iter): self._log.error( "Cannot calculate gain for album {} (incompatible formats)", album, ) return self._log.info("analyzing {}", album) discs: dict[int, list[Item]] = {} if self.config["per_disc"].get(bool): for item in album.items(): if discs.get(item.disc) is None: discs[item.disc] = [] discs[item.disc].append(item) else: discs[1] = album.items() def store_cb(task: RgTask): task.store(write) for discnumber, items in discs.items(): task = self.create_task(items, use_r128, album=album) try: self._apply( self.backend_instance.compute_album_gain, args=[task], kwds={}, callback=store_cb, ) except ReplayGainError as e: self._log.info("ReplayGain error: {}", e) except FatalReplayGainError as e: raise ui.UserError(f"Fatal replay gain error: {e}") def handle_track(self, item: Item, write: bool, force: bool = False): """Compute track replay gain and store it in the item. If ``write`` is truthy then ``item.write()`` is called to write the data to disk. If replay gain information is already present in the item, nothing is done. """ if not force and not self.track_requires_gain(item): self._log.info("Skipping track {}", item) return use_r128 = self.should_use_r128(item) def store_cb(task: RgTask): task.store(write) task = self.create_task([item], use_r128) try: self._apply( self.backend_instance.compute_track_gain, args=[task], kwds={}, callback=store_cb, ) except ReplayGainError as e: self._log.info("ReplayGain error: {}", e) except FatalReplayGainError as e: raise ui.UserError(f"Fatal replay gain error: {e}") def open_pool(self, threads: int): """Open a `ThreadPool` instance in `self.pool`""" if self.pool is None and self.backend_instance.do_parallel: self.pool = ThreadPool(threads) self.exc_queue: queue.Queue[Exception] = queue.Queue() signal.signal(signal.SIGINT, self._interrupt) self.exc_watcher = ExceptionWatcher( self.exc_queue, # threads push exceptions here self.terminate_pool, # abort once an exception occurs ) self.exc_watcher.start() def _apply( self, func: Callable[..., AnyRgTask], args: list[Any], kwds: dict[str, Any], callback: Callable[[AnyRgTask], Any], ): if self.pool is not None: def handle_exc(exc): """Handle exceptions in the async work.""" if isinstance(exc, ReplayGainError): self._log.info(exc.args[0]) # Log non-fatal exceptions. else: self.exc_queue.put(exc) self.pool.apply_async( func, args, kwds, callback, error_callback=handle_exc ) else: callback(func(*args, **kwds)) def terminate_pool(self): """Forcibly terminate the `ThreadPool` instance in `self.pool` Sends SIGTERM to all processes. """ if self.pool is not None: self.pool.terminate() self.pool.join() # Terminating the processes leaves the ExceptionWatcher's queues # in an unknown state, so don't wait for it. # self.exc_watcher.join() self.pool = None def _interrupt(self, signal, frame): try: self._log.info("interrupted") self.terminate_pool() sys.exit(0) except SystemExit: # Silence raised SystemExit ~ exit(0) pass def close_pool(self): """Regularly close the `ThreadPool` instance in `self.pool`.""" if self.pool is not None: self.pool.close() self.pool.join() self.exc_watcher.join() self.pool = None def import_begin(self, session: ImportSession): """Handle `import_begin` event -> open pool""" threads: int = self.config["threads"].get(int) if ( self.config["parallel_on_import"] and self.config["auto"] and threads ): self.open_pool(threads) def import_end(self, paths): """Handle `import` event -> close pool""" self.close_pool() def imported(self, session: ImportSession, task: ImportTask): """Add replay gain info to items or albums of ``task``.""" if self.config["auto"]: if task.is_album: self.handle_album(task.album, False, self.force_on_import) else: # Should be a SingletonImportTask assert hasattr(task, "item") self.handle_track(task.item, False, self.force_on_import) def command_func( self, lib: Library, opts: optparse.Values, args: list[str], ): try: write = ui.should_write(opts.write) force = opts.force # Bypass self.open_pool() if called with `--threads 0` if opts.threads != 0: threads: int = opts.threads or self.config["threads"].get(int) self.open_pool(threads) if opts.album: albums = lib.albums(args) self._log.info( f"Analyzing {len(albums)} albums ~" f" {self.backend_name} backend..." ) for album in albums: self.handle_album(album, write, force) else: items = lib.items(args) self._log.info( f"Analyzing {len(items)} tracks ~" f" {self.backend_name} backend..." ) for item in items: self.handle_track(item, write, force) self.close_pool() except (SystemExit, KeyboardInterrupt): # Silence interrupt exceptions pass def commands(self) -> list[ui.Subcommand]: """Return the "replaygain" ui subcommand.""" cmd = ui.Subcommand("replaygain", help="analyze for ReplayGain") cmd.parser.add_album_option() cmd.parser.add_option( "-t", "--threads", dest="threads", type=int, help=( "change the number of threads, defaults to maximum available" " processors" ), ) cmd.parser.add_option( "-f", "--force", dest="force", action="store_true", default=False, help=( "analyze all files, including those that already have" " ReplayGain metadata" ), ) cmd.parser.add_option( "-w", "--write", default=None, action="store_true", help="write new metadata to files' tags", ) cmd.parser.add_option( "-W", "--nowrite", dest="write", action="store_false", help="don't write metadata (opposite of -w)", ) cmd.func = self.command_func return [cmd] ================================================ FILE: beetsplug/rewrite.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Uses user-specified rewriting rules to canonicalize names for path formats. """ import re from collections import defaultdict from beets import library, ui from beets.plugins import BeetsPlugin def rewriter(field, rules): """Create a template field function that rewrites the given field with the given rewriting rules. ``rules`` must be a list of (pattern, replacement) pairs. """ def fieldfunc(item): value = item._values_fixed[field] for pattern, replacement in rules: if pattern.match(value.lower()): # Rewrite activated. return replacement # Not activated; return original value. return value return fieldfunc class RewritePlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({}) # Gather all the rewrite rules for each field. rules = defaultdict(list) for key, view in self.config.items(): value = view.as_str() try: fieldname, pattern = key.split(None, 1) except ValueError: raise ui.UserError("invalid rewrite specification") if fieldname not in library.Item._fields: raise ui.UserError( f"invalid field name ({fieldname}) in rewriter" ) self._log.debug("adding template field {}", key) pattern = re.compile(pattern.lower()) rules[fieldname].append((pattern, value)) if fieldname == "artist": # Special case for the artist field: apply the same # rewrite for "albumartist" as well. rules["albumartist"].append((pattern, value)) # Replace each template field with the new rewriter function. for fieldname, fieldrules in rules.items(): getter = rewriter(fieldname, fieldrules) self.template_fields[fieldname] = getter if fieldname in library.Album._fields: self.album_template_fields[fieldname] = getter ================================================ FILE: beetsplug/scrub.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Cleans extraneous metadata from files' tags via a command or automatically whenever tags are written. """ import mediafile import mutagen from beets import config, ui, util from beets.plugins import BeetsPlugin _MUTAGEN_FORMATS = { "asf": "ASF", "apev2": "APEv2File", "flac": "FLAC", "id3": "ID3FileType", "mp3": "MP3", "mp4": "MP4", "oggflac": "OggFLAC", "oggspeex": "OggSpeex", "oggtheora": "OggTheora", "oggvorbis": "OggVorbis", "oggopus": "OggOpus", "trueaudio": "TrueAudio", "wavpack": "WavPack", "monkeysaudio": "MonkeysAudio", "optimfrog": "OptimFROG", } class ScrubPlugin(BeetsPlugin): """Removes extraneous metadata from files' tags.""" def __init__(self): super().__init__() self.config.add( { "auto": True, } ) if self.config["auto"]: self.register_listener("import_task_files", self.import_task_files) def commands(self): def scrub_func(lib, opts, args): # Walk through matching files and remove tags. for item in lib.items(args): self._log.info("scrubbing: {.filepath}", item) self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand("scrub", help="clean audio tags") scrub_cmd.parser.add_option( "-W", "--nowrite", dest="write", action="store_false", default=True, help="leave tags empty", ) scrub_cmd.func = scrub_func return [scrub_cmd] @staticmethod def _mutagen_classes(): """Get a list of file type classes from the Mutagen module.""" classes = [] for modname, clsname in _MUTAGEN_FORMATS.items(): mod = __import__(f"mutagen.{modname}", fromlist=[clsname]) classes.append(getattr(mod, clsname)) return classes def _scrub(self, path): """Remove all tags from a file.""" for cls in self._mutagen_classes(): # Try opening the file with this type, but just skip in the # event of any error. try: f = cls(util.syspath(path)) except Exception: continue if f.tags is None: continue # Remove the tag for this type. try: f.delete() except NotImplementedError: # Some Mutagen metadata subclasses (namely, ASFTag) do not # support .delete(), presumably because it is impossible to # remove them. In this case, we just remove all the tags. for tag in f.keys(): del f[tag] f.save() except (OSError, mutagen.MutagenError) as exc: self._log.error( "could not scrub {}: {}", util.displayable_path(path), exc ) def _scrub_item(self, item, restore): """Remove tags from an Item's associated file and, if `restore` is enabled, write the database's tags back to the file. """ # Get album art if we need to restore it. if restore: try: mf = mediafile.MediaFile( util.syspath(item.path), config["id3v23"].get(bool) ) except mediafile.UnreadableFileError as exc: self._log.error("could not open file to scrub: {}", exc) return images = mf.images # Remove all tags. self._scrub(item.path) # Restore tags, if enabled. if restore: self._log.debug("writing new tags after scrub") item.try_write() if images: self._log.debug("restoring art") try: mf = mediafile.MediaFile( util.syspath(item.path), config["id3v23"].get(bool) ) mf.images = images mf.save() except mediafile.UnreadableFileError as exc: self._log.error("could not write tags: {}", exc) def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): self._log.debug("auto-scrubbing {.filepath}", item) self._scrub_item(item, ui.should_write()) ================================================ FILE: beetsplug/smartplaylist.py ================================================ # This file is part of beets. # Copyright 2016, Dang Mai <contact@dangmai.net>. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Generates smart playlists based on beets queries.""" from __future__ import annotations import os from typing import TYPE_CHECKING, Any, TypeAlias from urllib.parse import quote from urllib.request import pathname2url from beets import ui from beets.dbcore.query import ParsingError, Query, Sort from beets.library import Album, Item, parse_query_string from beets.plugins import BeetsPlugin from beets.plugins import send as send_event from beets.util import ( bytestring_path, displayable_path, mkdirall, normpath, path_as_posix, sanitize_path, syspath, ) if TYPE_CHECKING: from beets.library import Library QueryAndSort = tuple[Query, Sort] PlaylistQuery = Query | tuple[QueryAndSort, ...] | None PlaylistMatch: TypeAlias = tuple[ str, tuple[PlaylistQuery, Sort | None], tuple[PlaylistQuery, Sort | None], ] class SmartPlaylistPlugin(BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "dest_regen": False, "relative_to": None, "playlist_dir": ".", "auto": True, "playlists": [], "uri_format": None, "fields": [], "forward_slash": False, "prefix": "", "urlencode": False, "pretend_paths": False, "output": "m3u", } ) self.config["prefix"].redact = True # May contain username/password. self._matched_playlists: set[PlaylistMatch] = set() self._unmatched_playlists: set[PlaylistMatch] = set() if self.config["auto"]: self.register_listener("database_change", self.db_change) def commands(self) -> list[ui.Subcommand]: spl_update = ui.Subcommand( "splupdate", help="update the smart playlists. Playlist names may be " "passed as arguments.", ) spl_update.parser.add_option( "-p", "--pretend", action="store_true", help="display query results but don't write playlist files.", ) spl_update.parser.add_option( "--pretend-paths", action="store_true", dest="pretend_paths", help="in pretend mode, log the playlist item URIs/paths.", ) spl_update.parser.add_option( "-d", "--playlist-dir", dest="playlist_dir", metavar="PATH", type="string", help="directory to write the generated playlist files to.", ) spl_update.parser.add_option( "--dest-regen", action="store_true", dest="dest_regen", help="regenerate the destination path as 'move' or 'convert' " "commands would do.", ) spl_update.parser.add_option( "--relative-to", dest="relative_to", metavar="PATH", type="string", help="generate playlist item paths relative to this path.", ) spl_update.parser.add_option( "--prefix", type="string", help="prepend string to every path in the playlist file.", ) spl_update.parser.add_option( "--forward-slash", action="store_true", dest="forward_slash", help="force forward slash in paths within playlists.", ) spl_update.parser.add_option( "--urlencode", action="store_true", help="URL-encode all paths.", ) spl_update.parser.add_option( "--uri-format", dest="uri_format", type="string", help="playlist item URI template, e.g. http://beets:8337/item/$id/file.", ) spl_update.parser.add_option( "--output", type="string", help="specify the playlist format: m3u|extm3u.", ) spl_update.func = self.update_cmd return [spl_update] def update_cmd(self, lib: Library, opts: Any, args: list[str]) -> None: self.build_queries() if args: args_set = set(args) for a in list(args_set): if not a.endswith(".m3u"): args_set.add(f"{a}.m3u") playlists = { (name, q, a_q) for name, q, a_q in self._unmatched_playlists if name in args_set } if not playlists: unmatched = [name for name, _, _ in self._unmatched_playlists] raise ui.UserError( f"No playlist matching any of {unmatched} found" ) self._matched_playlists = playlists self._unmatched_playlists -= playlists else: self._matched_playlists = self._unmatched_playlists self.__apply_opts_to_config(opts) self.update_playlists(lib, opts.pretend) def __apply_opts_to_config(self, opts: Any) -> None: for k, v in opts.__dict__.items(): if v is not None and k in self.config: self.config[k] = v def _parse_one_query( self, playlist: dict[str, Any], key: str, model_cls: type ) -> tuple[PlaylistQuery, Sort | None]: qs = playlist.get(key) if qs is None: return None, None if isinstance(qs, str): return parse_query_string(qs, model_cls) if len(qs) == 1: return parse_query_string(qs[0], model_cls) queries_and_sorts: tuple[QueryAndSort, ...] = tuple( parse_query_string(q, model_cls) for q in qs ) return queries_and_sorts, None def build_queries(self) -> None: """ Instantiate queries for the playlists. Each playlist has 2 queries: one for items, one for albums, each with a sort. We must also remember its name. _unmatched_playlists is a set of tuples (name, (q, q_sort), (album_q, album_q_sort)). sort may be any sort, or NullSort, or None. None and NullSort are equivalent and both eval to False. More precisely - it will be NullSort when a playlist query ('query' or 'album_query') is a single item or a list with 1 element - it will be None when there are multiple items in a query """ self._unmatched_playlists = set() self._matched_playlists = set() for playlist in self.config["playlists"].get(list): if "name" not in playlist: self._log.warning("playlist configuration is missing name") continue try: q_match = self._parse_one_query(playlist, "query", Item) a_match = self._parse_one_query(playlist, "album_query", Album) except ParsingError as exc: self._log.warning( "invalid query in playlist {}: {}", playlist["name"], exc ) continue self._unmatched_playlists.add((playlist["name"], q_match, a_match)) def _matches_query(self, model: Item | Album, query: PlaylistQuery) -> bool: if not query: return False if isinstance(query, (list, tuple)): return any(q.match(model) for q, _ in query) return query.match(model) def matches( self, model: Item | Album, query: PlaylistQuery, album_query: PlaylistQuery, ) -> bool: if isinstance(model, Album): return self._matches_query(model, album_query) if isinstance(model, Item): return self._matches_query(model, query) return False def db_change(self, lib: Library, model: Item | Album) -> None: if self._unmatched_playlists is None: self.build_queries() for playlist in self._unmatched_playlists: n, (q, _), (a_q, _) = playlist if self.matches(model, q, a_q): self._log.debug("{} will be updated because of {}", n, model) self._matched_playlists.add(playlist) self.register_listener("cli_exit", self.update_playlists) self._unmatched_playlists -= self._matched_playlists def update_playlists(self, lib: Library, pretend: bool = False) -> None: if pretend: self._log.info( "Showing query results for {} smart playlists...", len(self._matched_playlists), ) else: self._log.info( "Updating {} smart playlists...", len(self._matched_playlists) ) playlist_dir = bytestring_path( self.config["playlist_dir"].as_filename() ) tpl = self.config["uri_format"].get() prefix = bytestring_path(self.config["prefix"].as_str()) dest_regen = self.config["dest_regen"].get() relative_to = self.config["relative_to"].get() if relative_to: relative_to = normpath(relative_to) # Maps playlist filenames to lists of track filenames. m3us: dict[str, list[PlaylistItem]] = {} for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist if pretend: self._log.info("Results for playlist {}:", name) else: self._log.info("Creating playlist {}", name) items = [] # Handle tuple/list of queries (preserves order) # Track seen items to avoid duplicates when an item matches # multiple queries seen_ids = set() if isinstance(query, (list, tuple)): for q, sort in query: for item in lib.items(q, sort): if item.id not in seen_ids: items.append(item) seen_ids.add(item.id) elif query: items.extend(lib.items(query, q_sort)) if isinstance(album_query, (list, tuple)): for q, sort in album_query: for album in lib.albums(q, sort): for item in album.items(): if item.id not in seen_ids: items.append(item) seen_ids.add(item.id) elif album_query: for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) # As we allow tags in the m3u names, we'll need to iterate through # the items and generate the correct m3u file names. for item in items: m3u_name = item.evaluate_template(name, True) m3u_name = sanitize_path(m3u_name, lib.replacements) if m3u_name not in m3us: m3us[m3u_name] = [] item_uri = item.path if tpl: item_uri = tpl.replace("$id", str(item.id)).encode("utf-8") else: if dest_regen is True: item_uri = item.destination() if relative_to: item_uri = os.path.relpath(item_uri, relative_to) if self.config["forward_slash"].get(): item_uri = path_as_posix(item_uri) if self.config["urlencode"]: item_uri = bytestring_path( pathname2url(os.fsdecode(item_uri)) ) item_uri = prefix + item_uri if item_uri not in m3us[m3u_name]: m3us[m3u_name].append(PlaylistItem(item, item_uri)) if pretend and self.config["pretend_paths"]: print(displayable_path(item_uri)) elif pretend: print(item) if not pretend: # Write all of the accumulated track lists to files. for m3u in m3us: m3u_path = normpath( os.path.join(playlist_dir, bytestring_path(m3u)) ) mkdirall(m3u_path) pl_format = self.config["output"].get() if pl_format != "m3u" and pl_format != "extm3u": msg = "Unsupported output format '{}' provided! " msg += "Supported: m3u, extm3u" raise Exception(msg.format(pl_format)) extm3u = pl_format == "extm3u" with open(syspath(m3u_path), "wb") as f: keys = [] if extm3u: keys = self.config["fields"].get(list) f.write(b"#EXTM3U\n") for entry in m3us[m3u]: item = entry.item comment = "" if extm3u: attr = [(k, entry.item[k]) for k in keys] al = [ f' {k}="{quote("; ".join(v) if isinstance(v, list) else str(v), safe="/:")}"' # noqa: E501 for k, v in attr ] attrs = "".join(al) comment = ( f"#EXTINF:{int(item.length)}{attrs}," f"{item.artist} - {item.title}\n" ) f.write(comment.encode("utf-8") + entry.uri + b"\n") # Send an event when playlists were updated. send_event("smartplaylist_update") # type: ignore if pretend: self._log.info( "Displayed results for {} playlists", len(self._matched_playlists), ) else: self._log.info("{} playlists updated", len(self._matched_playlists)) class PlaylistItem: def __init__(self, item: Item, uri: bytes) -> None: self.item = item self.uri = uri ================================================ FILE: beetsplug/sonosupdate.py ================================================ # This file is part of beets. # Copyright 2018, Tobias Sauerwein. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates a Sonos library whenever the beets library is changed. This is based on the Kodi Update plugin. """ import soco from beets.plugins import BeetsPlugin class SonosUpdate(BeetsPlugin): def __init__(self): super().__init__() self.register_listener("database_change", self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update""" self.register_listener("cli_exit", self.update) def update(self, lib): """When the client exists try to send refresh request to a Sonos controller. """ self._log.info("Requesting a Sonos library update...") device = soco.discovery.any_soco() if device: device.music_library.start_library_update() else: self._log.warning("Could not find a Sonos device.") return self._log.info("Sonos update triggered") ================================================ FILE: beetsplug/spotify.py ================================================ # This file is part of beets. # Copyright 2019, Rahul Ahuja. # Copyright 2022, Alok Saboo. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Spotify release and track search support to the autotagger. Also includes Spotify playlist construction. """ from __future__ import annotations import base64 import collections import json import re import threading import time import webbrowser from http import HTTPStatus from typing import TYPE_CHECKING, Any, ClassVar, Literal import confuse import requests from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.dbcore import types from beets.library import Library from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin if TYPE_CHECKING: from collections.abc import Sequence from beets.library import Item, Library from beets.metadata_plugins import QueryType, SearchParams from beetsplug._typing import JSONDict DEFAULT_WAITING_TIME = 5 class SearchResponseAlbums(IDResponse): """A response returned by the Spotify API. We only use items and disregard the pagination information. i.e. res["albums"]["items"][0]. There are more fields in the response, but we only type the ones we currently use. see https://developer.spotify.com/documentation/web-api/reference/search """ album_type: str available_markets: Sequence[str] name: str class SearchResponseTracks(IDResponse): """A track response returned by the Spotify API.""" album: SearchResponseAlbums available_markets: Sequence[str] popularity: int name: str class APIError(Exception): pass class AudioFeaturesUnavailableError(Exception): """Raised when audio features API returns 403 (deprecated).""" pass class SpotifyPlugin( SearchApiMetadataSourcePlugin[SearchResponseAlbums | SearchResponseTracks] ): item_types: ClassVar[dict[str, types.Type]] = { "spotify_track_popularity": types.INTEGER, "spotify_acousticness": types.FLOAT, "spotify_danceability": types.FLOAT, "spotify_energy": types.FLOAT, "spotify_instrumentalness": types.FLOAT, "spotify_key": types.FLOAT, "spotify_liveness": types.FLOAT, "spotify_loudness": types.FLOAT, "spotify_mode": types.INTEGER, "spotify_speechiness": types.FLOAT, "spotify_tempo": types.FLOAT, "spotify_time_signature": types.INTEGER, "spotify_valence": types.FLOAT, "spotify_updated": types.DATE, } # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = "https://accounts.spotify.com/api/token" open_track_url = "https://open.spotify.com/track/" search_url = "https://api.spotify.com/v1/search" album_url = "https://api.spotify.com/v1/albums/" track_url = "https://api.spotify.com/v1/tracks/" audio_features_url = "https://api.spotify.com/v1/audio-features/" spotify_audio_features: ClassVar[dict[str, str]] = { "acousticness": "spotify_acousticness", "danceability": "spotify_danceability", "energy": "spotify_energy", "instrumentalness": "spotify_instrumentalness", "key": "spotify_key", "liveness": "spotify_liveness", "loudness": "spotify_loudness", "mode": "spotify_mode", "speechiness": "spotify_speechiness", "tempo": "spotify_tempo", "time_signature": "spotify_time_signature", "valence": "spotify_valence", } def __init__(self): super().__init__() self.config.add( { "mode": "list", "tiebreak": "popularity", "show_failures": False, "region_filter": None, "regex": [], "client_id": "4e414367a1d14c75a5c5129a627fcab8", "client_secret": "4a9b5b7848e54e118a7523b1c7c3e1e5", "tokenfile": "spotify_token.json", } ) self.config["client_id"].redact = True self.config["client_secret"].redact = True self.audio_features_available = ( True # Track if audio features API is available ) self._audio_features_lock = ( threading.Lock() ) # Protects audio_features_available self.setup() def setup(self): """Retrieve previously saved OAuth token or generate a new one.""" try: with open(self._tokenfile()) as f: token_data = json.load(f) except OSError: self._authenticate() else: self.access_token = token_data["access_token"] def _tokenfile(self) -> str: """Get the path to the JSON file for storing the OAuth token.""" return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def _authenticate(self) -> None: """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow""" c_id: str = self.config["client_id"].as_str() c_secret: str = self.config["client_secret"].as_str() headers = { "Authorization": ( "Basic" f" {base64.b64encode(f'{c_id}:{c_secret}'.encode()).decode()}" ) } response = requests.post( self.oauth_token_url, data={"grant_type": "client_credentials"}, headers=headers, timeout=10, ) try: response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( f"Spotify authorization failed: {e}\n{response.text}" ) self.access_token = response.json()["access_token"] # Save the token for later use. self._log.debug("{0.data_source} access token: {0.access_token}", self) with open(self._tokenfile(), "w") as f: json.dump({"access_token": self.access_token}, f) def _handle_response( self, method: Literal["get", "post", "put", "delete"], url: str, params: Any = None, retry_count: int = 0, max_retries: int = 3, ) -> JSONDict: """Send a request, reauthenticating if necessary. :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. :param dict params: (optional) list of tuples or bytes to send in the query string for the :class:`Request`. """ if retry_count > max_retries: raise APIError("Maximum retries reached.") try: response = requests.request( method, url, headers={"Authorization": f"Bearer {self.access_token}"}, params=params, timeout=10, ) response.raise_for_status() return response.json() except requests.exceptions.ReadTimeout: self._log.error("ReadTimeout.") raise APIError("Request timed out.") except requests.exceptions.ConnectionError as e: self._log.error("Network error: {}", e) raise APIError("Network error.") except requests.exceptions.RequestException as e: if e.response is None: self._log.error("Request failed: {}", e) raise APIError("Request failed.") if e.response.status_code == 401: self._log.debug( "{.data_source} access token has expired. Reauthenticating.", self, ) self._authenticate() return self._handle_response( method, url, params=params, retry_count=retry_count + 1, ) elif e.response.status_code == 404: raise APIError( f"API Error: {e.response.status_code}\n" f"URL: {url}\nparams: {params}" ) elif e.response.status_code == 403: # Check if this is the audio features endpoint if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( "Audio features API returned 403 " "(deprecated or unavailable)" ) raise APIError( f"API Error: {e.response.status_code}\n" f"URL: {url}\nparams: {params}" ) elif e.response.status_code == 429: seconds = e.response.headers.get( "Retry-After", DEFAULT_WAITING_TIME ) self._log.debug( "Too many API requests. Retrying after {} seconds.", seconds ) time.sleep(int(seconds) + 1) return self._handle_response( method, url, params=params, retry_count=retry_count + 1, ) elif e.response.status_code == 503: self._log.error("Service Unavailable.") raise APIError("Service Unavailable.") elif e.response.status_code == 502: self._log.error("Bad Gateway.") raise APIError("Bad Gateway.") elif e.response is not None: raise APIError( f"{self.data_source} API error:\n" f"{e.response.text}\n" f"URL:\n{url}\nparams:\n{params}" ) else: self._log.error("Request failed. Error: {}", e) raise APIError("Request failed.") def _multi_artist_credit( self, artists: list[dict[str | int, str]] ) -> tuple[list[str], list[str]]: """Given a list of artist dictionaries, accumulate data into a pair of lists: the first being the artist names, and the second being the artist IDs. """ artist_names = [] artist_ids = [] for artist in artists: artist_names.append(artist["name"]) artist_ids.append(artist["id"]) return artist_names, artist_ids def album_for_id(self, album_id: str) -> AlbumInfo | None: """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. :param str album_id: Spotify ID or URL for the album :returns: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None """ if not (spotify_id := self._extract_id(album_id)): return None album_data = self._handle_response( "get", f"{self.album_url}{spotify_id}" ) if album_data["name"] == "": self._log.debug("Album removed from Spotify: {}", album_id) return None artists_names, artists_ids = self._multi_artist_credit( album_data["artists"] ) artist = ", ".join(artists_names) date_parts = [ int(part) for part in album_data["release_date"].split("-") ] release_date_precision = album_data["release_date_precision"] if release_date_precision == "day": year, month, day = date_parts elif release_date_precision == "month": year, month = date_parts day = None elif release_date_precision == "year": year = date_parts[0] month = None day = None else: raise ui.UserError( "Invalid `release_date_precision` returned " f"by {self.data_source} API: '{release_date_precision}'" ) tracks_data = album_data["tracks"] tracks_items = tracks_data["items"] while tracks_data["next"]: tracks_data = self._handle_response("get", tracks_data["next"]) tracks_items.extend(tracks_data["items"]) tracks = [] medium_totals: dict[int | None, int] = collections.defaultdict(int) for i, track_data in enumerate(tracks_items, start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: track.medium_total = medium_totals[track.medium] return AlbumInfo( album=album_data["name"], album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, artist_id=artists_ids[0] if len(artists_ids) > 0 else None, spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, tracks=tracks, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 and artist.lower() == "various artists", year=year, month=month, day=day, label=album_data["label"], mediums=max(filter(None, medium_totals.keys())), data_source=self.data_source, data_url=album_data["external_urls"]["spotify"], ) def _get_track(self, track_data: JSONDict) -> TrackInfo: """Convert a Spotify track object dict to a TrackInfo object. :param track_data: Simplified track object (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) :returns: TrackInfo object for track """ artists_names, artists_ids = self._multi_artist_credit( track_data["artists"] ) artist = ", ".join(artists_names) # Get album information for spotify tracks try: album = track_data["album"]["name"] except (KeyError, TypeError): album = None return TrackInfo( title=track_data["name"], track_id=track_data["id"], spotify_track_id=track_data["id"], artist=artist, album=album, artist_id=artists_ids[0] if len(artists_ids) > 0 else None, spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, index=track_data["track_number"], medium=track_data["disc_number"], medium_index=track_data["track_number"], data_source=self.data_source, data_url=track_data["external_urls"]["spotify"], ) def track_for_id(self, track_id: str) -> None | TrackInfo: """Fetch a track by its Spotify ID or URL. Returns a TrackInfo object or None if the track is not found. """ if not (spotify_id := self._extract_id(track_id)): self._log.debug("Invalid Spotify ID: {}", track_id) return None if not ( track_data := self._handle_response( "get", f"{self.track_url}{spotify_id}" ) ): self._log.debug("Track not found: {}", track_id) return None track = self._get_track(track_data) # Get album's tracks to set `track.index` (position on the entire # release) and `track.medium_total` (total number of tracks on # the track's disc). album_data = self._handle_response( "get", f"{self.album_url}{track_data['album']['id']}" ) medium_total = 0 for i, track_data in enumerate(album_data["tracks"]["items"], start=1): if track_data["disc_number"] == track.medium: medium_total += 1 if track_data["id"] == track.track_id: track.index = i track.medium_total = medium_total return track def get_search_query_with_filters( self, query_type: QueryType, items: Sequence[Item], artist: str, name: str, va_likely: bool, ) -> tuple[str, dict[str, str]]: query = f'album:"{name}"' if query_type == "album" else name if query_type == "track" or not va_likely: query += f' artist:"{artist}"' return query, {} def get_search_response( self, params: SearchParams ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: """Search Spotify and return raw album or track result items. Unauthorized responses trigger one token refresh attempt before the method gives up and falls back to an empty result set. """ for _ in range(2): response = requests.get( self.search_url, headers={"Authorization": f"Bearer {self.access_token}"}, params={ **params.filters, "q": params.query, "type": params.query_type, "limit": str(params.limit), }, timeout=10, ) try: response.raise_for_status() except requests.exceptions.HTTPError: if response.status_code == HTTPStatus.UNAUTHORIZED: self._authenticate() continue raise return ( response.json() .get(f"{params.query_type}s", {}) .get("items", []) ) return () def commands(self) -> list[ui.Subcommand]: # autotagger import command def queries(lib, opts, args): success = self._parse_opts(opts) if success: results = self._match_library_tracks(lib, args) self._output_match_results(results) spotify_cmd = ui.Subcommand( "spotify", help=f"build a {self.data_source} playlist" ) spotify_cmd.parser.add_option( "-m", "--mode", action="store", help=( f'"open" to open {self.data_source} with playlist, ' '"list" to print (default)' ), ) spotify_cmd.parser.add_option( "-f", "--show-failures", action="store_true", dest="show_failures", help=f"list tracks that did not match a {self.data_source} ID", ) spotify_cmd.func = queries # spotifysync command sync_cmd = ui.Subcommand( "spotifysync", help="fetch track attributes from Spotify" ) sync_cmd.parser.add_option( "-f", "--force", dest="force_refetch", action="store_true", default=False, help="re-download data when already present", ) def func(lib, opts, args): items = lib.items(args) self._fetch_info(items, ui.should_write(), opts.force_refetch) sync_cmd.func = func return [spotify_cmd, sync_cmd] def _parse_opts(self, opts): if opts.mode: self.config["mode"].set(opts.mode) if opts.show_failures: self.config["show_failures"].set(True) if self.config["mode"].get() not in ["list", "open"]: self._log.warning( "{} is not a valid mode", self.config["mode"].get() ) return False self.opts = opts return True def _match_library_tracks(self, library: Library, keywords: str): """Get simplified track object dicts for library tracks. Matches tracks based on the specified ``keywords``. :param library: beets library object to query. :param keywords: Query to match library items against. :returns: List of simplified track object dicts for library items matching the specified query. """ results = [] failures = [] items = library.items(keywords) if not items: self._log.debug( "Your beets query returned no items, skipping {.data_source}.", self, ) return self._log.info("Processing {} tracks...", len(items)) for item in items: # Apply regex transformations if provided for regex in self.config["regex"].get(): if ( not regex["field"] or not regex["search"] or not regex["replace"] ): continue value = item[regex["field"]] item[regex["field"]] = re.sub( regex["search"], regex["replace"], value ) artist = item["artist"] or item["albumartist"] album = item["album"] query_string = item["title"] # Query the Web API for each track, look for the items' JSON data query = query_string if artist: query += f" artist:'{artist}'" if album: query += f" album:'{album}'" response_data_tracks = self._search_api("track", query, {}) if not response_data_tracks: failures.append(query) continue # Apply market filter if requested region_filter: str = self.config["region_filter"].get() if region_filter: response_data_tracks = [ track_data for track_data in response_data_tracks if region_filter in track_data["available_markets"] ] if ( len(response_data_tracks) == 1 or self.config["tiebreak"].get() == "first" ): self._log.debug( "{.data_source} track(s) found, count: {}", self, len(response_data_tracks), ) chosen_result = response_data_tracks[0] else: # Use the popularity filter self._log.debug( "Most popular track chosen, count: {}", len(response_data_tracks), ) chosen_result = max( response_data_tracks, key=lambda x: x[ # We are sure this is a track response! "popularity" # type: ignore[typeddict-item] ], ) results.append(chosen_result) failure_count = len(failures) if failure_count > 0: if self.config["show_failures"].get(): self._log.info( "{} track(s) did not match a {.data_source} ID:", failure_count, self, ) for track in failures: self._log.info("track: {}", track) self._log.info("") else: self._log.warning( "{} track(s) did not match a {.data_source} ID:\n" "use --show-failures to display", failure_count, self, ) return results def _output_match_results(self, results): """Open a playlist or print Spotify URLs. Uses the provided track object dicts. :param list[dict] results: List of simplified track object dicts (https://developer.spotify.com/documentation/web-api/ reference/object-model/#track-object-simplified) """ if results: spotify_ids = [track_data["id"] for track_data in results] if self.config["mode"].get() == "open": self._log.info( "Attempting to open {.data_source} with playlist", self ) spotify_url = ( f"spotify:trackset:Playlist:{','.join(spotify_ids)}" ) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: print(f"{self.open_track_url}{spotify_id}") else: self._log.warning( "No {.data_source} tracks found from beets query", self ) def _fetch_info(self, items, write, force): """Obtain track information from Spotify.""" self._log.debug("Total {} tracks", len(items)) for index, item in enumerate(items, start=1): self._log.info( "Processing {}/{} tracks - {} ", index, len(items), item ) # If we're not forcing re-downloading for all tracks, check # whether the popularity data is already present if not force: if "spotify_track_popularity" in item: self._log.debug("Popularity already present for: {}", item) continue try: spotify_track_id = item.spotify_track_id except AttributeError: self._log.debug("No track_id present for: {}", item) continue popularity, isrc, ean, upc = self.track_info(spotify_track_id) item["spotify_track_popularity"] = popularity item["isrc"] = isrc item["ean"] = ean item["upc"] = upc if self.audio_features_available: audio_features = self.track_audio_features(spotify_track_id) if audio_features is None: self._log.info("No audio features found for: {}", item) else: for feature, value in audio_features.items(): if feature in self.spotify_audio_features: item[self.spotify_audio_features[feature]] = value else: self._log.debug("Audio features API unavailable, skipping") item["spotify_updated"] = time.time() item.store() if write: item.try_write() def track_info(self, track_id: str): """Fetch a track's popularity and external IDs using its Spotify ID.""" track_data = self._handle_response("get", f"{self.track_url}{track_id}") external_ids = track_data.get("external_ids", {}) popularity = track_data.get("popularity") self._log.debug( "track_popularity: {} and track_isrc: {}", popularity, external_ids.get("isrc"), ) return ( popularity, external_ids.get("isrc"), external_ids.get("ean"), external_ids.get("upc"), ) def track_audio_features(self, track_id: str): """Fetch track audio features by its Spotify ID. Thread-safe: avoids redundant API calls and logs the 403 warning only once. """ # Fast path: if we've already detected unavailability, skip the call. with self._audio_features_lock: if not self.audio_features_available: return None try: return self._handle_response( "get", f"{self.audio_features_url}{track_id}" ) except AudioFeaturesUnavailableError: # Disable globally in a thread-safe manner and warn once. should_log = False with self._audio_features_lock: if self.audio_features_available: self.audio_features_available = False should_log = True if should_log: self._log.warning( "Audio features API is unavailable (403 error). " "Skipping audio features for remaining tracks." ) return None except APIError as e: self._log.debug("Spotify API error: {}", e) return None ================================================ FILE: beetsplug/subsonicplaylist.py ================================================ # This file is part of beets. # Copyright 2019, Joris Jensen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import random import string from hashlib import md5 from urllib.parse import urlencode from xml.etree import ElementTree import requests from beets.dbcore import AndQuery from beets.dbcore.query import MatchQuery from beets.plugins import BeetsPlugin from beets.ui import Subcommand __author__ = "https://github.com/MrNuggelz" def filter_to_be_removed(items, keys): if len(items) > len(keys): dont_remove = [] for artist, album, title in keys: for item in items: if ( artist == item["artist"] and album == item["album"] and title == item["title"] ): dont_remove.append(item) return [item for item in items if item not in dont_remove] else: def to_be_removed(item): for artist, album, title in keys: if ( artist == item["artist"] and album == item["album"] and title == item["title"] ): return False return True return [item for item in items if to_be_removed(item)] class SubsonicPlaylistPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "delete": False, "playlist_ids": [], "playlist_names": [], "username": "", "password": "", } ) self.config["password"].redact = True def update_tags(self, playlist_dict, lib): with lib.transaction(): for query, playlist_tag in playlist_dict.items(): query = AndQuery( [ MatchQuery("artist", query[0]), MatchQuery("album", query[1]), MatchQuery("title", query[2]), ] ) items = lib.items(query) if not items: self._log.warn( "{} | track not found ({})", playlist_tag, query ) continue for item in items: item.subsonic_playlist = playlist_tag item.try_sync(write=True, move=False) def get_playlist(self, playlist_id): xml = self.send("getPlaylist", {"id": playlist_id}).text playlist = ElementTree.fromstring(xml)[0] if playlist.attrib.get("code", "200") != "200": alt_error = "error getting playlist, but no error message found" self._log.warn(playlist.attrib.get("message", alt_error)) return name = playlist.attrib.get("name", "undefined") tracks = [ (t.attrib["artist"], t.attrib["album"], t.attrib["title"]) for t in playlist ] return name, tracks def commands(self): def build_playlist(lib, opts, args): self.config.set_args(opts) ids = self.config["playlist_ids"].as_str_seq() if self.config["playlist_names"].as_str_seq(): playlists = ElementTree.fromstring( self.send("getPlaylists").text )[0] if playlists.attrib.get("code", "200") != "200": alt_error = ( "error getting playlists, but no error message found" ) self._log.warn(playlists.attrib.get("message", alt_error)) return for name in self.config["playlist_names"].as_str_seq(): for playlist in playlists: if name == playlist.attrib["name"]: ids.append(playlist.attrib["id"]) playlist_dict = self.get_playlists(ids) # delete old tags if self.config["delete"]: existing = list(lib.items('subsonic_playlist:";"')) to_be_removed = filter_to_be_removed( existing, playlist_dict.keys() ) for item in to_be_removed: item["subsonic_playlist"] = "" with lib.transaction(): item.try_sync(write=True, move=False) self.update_tags(playlist_dict, lib) subsonicplaylist_cmds = Subcommand( "subsonicplaylist", help="import a subsonic playlist" ) subsonicplaylist_cmds.parser.add_option( "-d", "--delete", action="store_true", help="delete tag from items not in any playlist anymore", ) subsonicplaylist_cmds.func = build_playlist return [subsonicplaylist_cmds] def generate_token(self): salt = "".join(random.choices(string.ascii_lowercase + string.digits)) return ( md5((self.config["password"].get() + salt).encode()).hexdigest(), salt, ) def send(self, endpoint, params=None): if params is None: params = {} a, b = self.generate_token() params["u"] = self.config["username"] params["t"] = a params["s"] = b params["v"] = "1.12.0" params["c"] = "beets" resp = requests.get( f"{self.config['base_url'].get()}/rest/{endpoint}?{urlencode(params)}", timeout=10, ) return resp def get_playlists(self, ids): output = {} for playlist_id in ids: name, tracks = self.get_playlist(playlist_id) for track in tracks: if track not in output: output[track] = ";" output[track] += f"{name};" return output ================================================ FILE: beetsplug/subsonicupdate.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates Subsonic library on Beets import Your Beets configuration file should contain a "subsonic" section like the following: subsonic: url: https://mydomain.com:443/subsonic user: username pass: password auth: token For older Subsonic versions, token authentication is not supported, use password instead: subsonic: url: https://mydomain.com:443/subsonic user: username pass: password auth: pass """ import hashlib import random import string from binascii import hexlify import requests from beets.plugins import BeetsPlugin __author__ = "https://github.com/maffo999" class SubsonicUpdate(BeetsPlugin): def __init__(self): super().__init__("subsonic") # Set default configuration values self.config.add( { "user": "admin", "pass": "admin", "url": "http://localhost:4040", "auth": "token", } ) self.config["user"].redact = True self.config["pass"].redact = True self.register_listener("database_change", self.db_change) self.register_listener("smartplaylist_update", self.spl_update) def db_change(self, lib, model): self.register_listener("cli_exit", self.start_scan) def spl_update(self): self.register_listener("cli_exit", self.start_scan) def __create_token(self): """Create salt and token from given password. :return: The generated salt and hashed token """ password = self.config["pass"].as_str() # Pick the random sequence and salt the password r = string.ascii_letters + string.digits salt = "".join([random.choice(r) for _ in range(6)]) salted_password = f"{password}{salt}" token = hashlib.md5(salted_password.encode("utf-8")).hexdigest() # Put together the payload of the request to the server and the URL return salt, token def __format_url(self, endpoint): """Get the Subsonic URL to trigger the given endpoint. Uses either the url config option or the deprecated host, port, and context_path config options together. :return: Endpoint for updating Subsonic """ url = self.config["url"].as_str() if url and url.endswith("/"): url = url[:-1] # @deprecated("Use url config option instead") if not url: host = self.config["host"].as_str() port = self.config["port"].get(int) context_path = self.config["contextpath"].as_str() if context_path == "/": context_path = "" url = f"http://{host}:{port}{context_path}" return f"{url}/rest/{endpoint}" def start_scan(self): user = self.config["user"].as_str() auth = self.config["auth"].as_str() url = self.__format_url("startScan") self._log.debug("URL is {}", url) self._log.debug("auth type is {.config[auth]}", self) if auth == "token": salt, token = self.__create_token() payload = { "u": user, "t": token, "s": salt, "v": "1.13.0", # Subsonic 5.3 and newer "c": "beets", "f": "json", } elif auth == "password": password = self.config["pass"].as_str() encpass = hexlify(password.encode()).decode() payload = { "u": user, "p": f"enc:{encpass}", "v": "1.12.0", "c": "beets", "f": "json", } else: return try: response = requests.get( url, params=payload, timeout=10, ) json = response.json() if ( response.status_code == 200 and json["subsonic-response"]["status"] == "ok" ): count = json["subsonic-response"]["scanStatus"]["count"] self._log.info("Updating Subsonic; scanning {} tracks", count) elif ( response.status_code == 200 and json["subsonic-response"]["status"] == "failed" ): self._log.error( "Error: {[subsonic-response][error][message]}", json ) else: self._log.error("Error: {}", json) except Exception as error: self._log.error("Error: {}", error) ================================================ FILE: beetsplug/substitute.py ================================================ # This file is part of beets. # Copyright 2023, Daniele Ferone. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The substitute plugin module. Uses user-specified substitution rules to canonicalize names for path formats. """ import re from beets.plugins import BeetsPlugin class Substitute(BeetsPlugin): """The substitute plugin class. Create a template field function that substitute the given field with the given substitution rules. ``rules`` must be a list of (pattern, replacement) pairs. """ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: text = pattern.sub(replacement, text) return text else: return "" def __init__(self): """Initialize the substitute plugin. Get the configuration, register template function and create list of substitute rules. """ super().__init__() self.template_funcs["substitute"] = self.tmpl_substitute self.substitute_rules = [ (re.compile(key, flags=re.IGNORECASE), value) for key, value in self.config.flatten().items() ] ================================================ FILE: beetsplug/the.py ================================================ # This file is part of beets. # Copyright 2016, Blemjhoo Tezoulbr <baobab@heresiarch.info>. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Moves patterns in path formats (suitable for moving articles).""" import re from typing import ClassVar from beets.plugins import BeetsPlugin __author__ = "baobab@heresiarch.info" __version__ = "1.1" PATTERN_THE = "^the\\s" PATTERN_A = "^[a][n]?\\s" FORMAT = "{}, {}" class ThePlugin(BeetsPlugin): patterns: ClassVar[list[str]] = [] def __init__(self): super().__init__() self.template_funcs["the"] = self.the_template_func self.config.add( { "the": True, "a": True, "format": "{}, {}", "strip": False, "patterns": [], } ) self.patterns = self.config["patterns"].as_str_seq() for p in self.patterns: if p: try: re.compile(p) except re.error: self._log.error("invalid pattern: {}", p) else: if not (p.startswith("^") or p.endswith("$")): self._log.warning( 'warning: "{}" will not match string start/end', p, ) if self.config["a"]: self.patterns = [PATTERN_A, *self.patterns] if self.config["the"]: self.patterns = [PATTERN_THE, *self.patterns] if not self.patterns: self._log.warning("no patterns defined!") def unthe(self, text, pattern): """Moves pattern in the path format string or strips it text -- text to handle pattern -- regexp pattern (case ignore is already on) strip -- if True, pattern will be removed """ if text: r = re.compile(pattern, flags=re.IGNORECASE) try: t = r.findall(text)[0] except IndexError: return text else: r = re.sub(r, "", text).strip() if self.config["strip"]: return r else: fmt = self.config["format"].as_str() return fmt.format(r, t.strip()).strip() else: return "" def the_template_func(self, text): if not self.patterns: return text if text: for p in self.patterns: r = self.unthe(text, p) if r != text: self._log.debug('"{}" -> "{}"', text, r) break return r else: return "" ================================================ FILE: beetsplug/thumbnails.py ================================================ # This file is part of beets. # Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Create freedesktop.org-compliant thumbnails for album folders This plugin is POSIX-only. Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html """ import ctypes import ctypes.util import os import shutil from hashlib import md5 from pathlib import PurePosixPath from xdg import BaseDirectory from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import bytestring_path, displayable_path, syspath from beets.util.artresizer import ArtResizer BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") NORMAL_DIR = bytestring_path(os.path.join(BASE_DIR, "normal")) LARGE_DIR = bytestring_path(os.path.join(BASE_DIR, "large")) class ThumbnailsPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "auto": True, "force": False, "dolphin": False, } ) if self.config["auto"] and self._check_local_ok(): self.register_listener("art_set", self.process_album) def commands(self): thumbnails_command = Subcommand( "thumbnails", help="Create album thumbnails" ) thumbnails_command.parser.add_option( "-f", "--force", dest="force", action="store_true", default=False, help="force regeneration of thumbnails deemed fine (existing & " "recent enough)", ) thumbnails_command.parser.add_option( "--dolphin", dest="dolphin", action="store_true", default=False, help="create Dolphin-compatible thumbnail information (for KDE)", ) thumbnails_command.func = self.process_query return [thumbnails_command] def process_query(self, lib, opts, args): self.config.set_args(opts) if self._check_local_ok(): for album in lib.albums(args): self.process_album(album) def _check_local_ok(self): """Check that everything is ready: - local capability to resize images - thumbnail dirs exist (create them if needed) - detect whether we'll use PIL or IM - detect whether we'll use GIO or Python to get URIs """ if not ArtResizer.shared.local: self._log.warning( "No local image resizing capabilities, " "cannot generate thumbnails" ) return False for dir in (NORMAL_DIR, LARGE_DIR): if not os.path.exists(syspath(dir)): os.makedirs(syspath(dir)) if not ArtResizer.shared.can_write_metadata: raise RuntimeError( f"Thumbnails: ArtResizer backend {ArtResizer.shared.method}" f" unexpectedly cannot write image metadata." ) self._log.debug("using {.shared.method} to write metadata", ArtResizer) uri_getter = GioURI() if not uri_getter.available: uri_getter = PathlibURI() self._log.debug("using {.name} to compute URIs", uri_getter) self.get_uri = uri_getter.uri return True def process_album(self, album): """Produce thumbnails for the album folder.""" self._log.debug("generating thumbnail for {}", album) if not album.artpath: self._log.info("album {} has no art", album) return if self.config["dolphin"]: self.make_dolphin_cover_thumbnail(album) size = ArtResizer.shared.get_size(album.artpath) if not size: self._log.warning( "problem getting the picture size for {.artpath}", album ) return wrote = True if max(size) >= 256: wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR) wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR) if wrote: self._log.info("wrote thumbnail for {}", album) else: self._log.info("nothing to do for {}", album) def make_cover_thumbnail(self, album, size, target_dir): """Make a thumbnail of given size for `album` and put it in `target_dir`. """ target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) if ( os.path.exists(syspath(target)) and os.stat(syspath(target)).st_mtime > os.stat(syspath(album.artpath)).st_mtime ): if self.config["force"]: self._log.debug( "found a suitable {0}x{0} thumbnail for {1}, " "forcing regeneration", size, album, ) else: self._log.debug( "{0}x{0} thumbnail for {1} exists and is recent enough", size, album, ) return False resized = ArtResizer.shared.resize(size, album.artpath, target) self.add_tags(album, resized) shutil.move(syspath(resized), syspath(target)) return True def thumbnail_file_name(self, path): """Compute the thumbnail file name See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html """ uri = self.get_uri(path) hash = md5(uri.encode("utf-8")).hexdigest() return bytestring_path(f"{hash}.png") def add_tags(self, album, image_path): """Write required metadata to the thumbnail See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html """ mtime = os.stat(syspath(album.artpath)).st_mtime metadata = { "Thumb::URI": self.get_uri(album.artpath), "Thumb::MTime": str(mtime), } try: ArtResizer.shared.write_metadata(image_path, metadata) except Exception: self._log.exception( "could not write metadata to {}", displayable_path(image_path) ) def make_dolphin_cover_thumbnail(self, album): outfilename = os.path.join(album.path, b".directory") if os.path.exists(syspath(outfilename)): return artfile = os.path.split(album.artpath)[1] with open(syspath(outfilename), "w") as f: f.write("[Desktop Entry]\n") f.write(f"Icon=./{artfile.decode('utf-8')}") f.close() self._log.debug("Wrote file {}", displayable_path(outfilename)) class URIGetter: available = False name = "Abstract base" def uri(self, path): raise NotImplementedError() class PathlibURI(URIGetter): available = True name = "Python Pathlib" def uri(self, path): return PurePosixPath(os.fsdecode(path)).as_uri() def copy_c_string(c_string): """Copy a `ctypes.POINTER(ctypes.c_char)` value into a new Python string and return it. The old memory is then safe to free. """ # This is a pretty dumb way to get a string copy, but it seems to # work. A more surefire way would be to allocate a ctypes buffer and copy # the data with `memcpy` or somesuch. return ctypes.cast(c_string, ctypes.c_char_p).value class GioURI(URIGetter): """Use gio URI function g_file_get_uri. Paths must be utf-8 encoded.""" name = "GIO" def __init__(self): self.libgio = self.get_library() self.available = bool(self.libgio) if self.available: self.libgio.g_type_init() # for glib < 2.36 self.libgio.g_file_new_for_path.argtypes = [ctypes.c_char_p] self.libgio.g_file_new_for_path.restype = ctypes.c_void_p self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p] self.libgio.g_file_get_uri.restype = ctypes.POINTER(ctypes.c_char) self.libgio.g_object_unref.argtypes = [ctypes.c_void_p] def get_library(self): lib_name = ctypes.util.find_library("gio-2") try: if not lib_name: return False return ctypes.cdll.LoadLibrary(lib_name) except OSError: return False def uri(self, path): g_file_ptr = self.libgio.g_file_new_for_path(path) if not g_file_ptr: raise RuntimeError( f"No gfile pointer received for {displayable_path(path)}" ) try: uri_ptr = self.libgio.g_file_get_uri(g_file_ptr) finally: self.libgio.g_object_unref(g_file_ptr) if not uri_ptr: self.libgio.g_free(uri_ptr) raise RuntimeError( f"No URI received from the gfile pointer for {displayable_path(path)}" ) try: uri = copy_c_string(uri_ptr) finally: self.libgio.g_free(uri_ptr) try: return os.fsdecode(uri) except UnicodeDecodeError: raise RuntimeError(f"Could not decode filename from GIO: {uri!r}") ================================================ FILE: beetsplug/titlecase.py ================================================ # This file is part of beets. # Copyright 2025, Henry Oberholtzer # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Apply NYT manual of style title case rules, to text. Title case logic is derived from the python-titlecase library. Provides a template function and a tag modification function.""" from __future__ import annotations import re from functools import cached_property from typing import TYPE_CHECKING, TypedDict from titlecase import titlecase from beets import ui from beets.autotag.hooks import AlbumInfo from beets.plugins import BeetsPlugin if TYPE_CHECKING: from beets.autotag.hooks import Info from beets.importer import ImportSession, ImportTask from beets.library import Item __author__ = "henryoberholtzer@gmail.com" __version__ = "1.0" class PreservedText(TypedDict): words: dict[str, str] phrases: dict[str, re.Pattern[str]] class TitlecasePlugin(BeetsPlugin): def __init__(self) -> None: super().__init__() self.config.add( { "auto": True, "preserve": [], "fields": [], "replace": [], "separators": [], "force_lowercase": False, "small_first_last": True, "the_artist": True, "all_caps": False, "all_lowercase": False, "after_choice": False, } ) """ auto - Automatically apply titlecase to new import metadata. preserve - Provide a list of strings with specific case requirements. fields - Fields to apply titlecase to. replace - List of pairs, first is the target, second is the replacement separators - Other characters to treat like periods. force_lowercase - Lowercase the string before titlecase. small_first_last - If small characters should be cased at the start of strings. the_artist - If the plugin infers the field to be an artist field (e.g. the field contains "artist") It will capitalize a lowercase The, helpful for the artist names that start with 'The', like 'The Who' or 'The Talking Heads' when they are not at the start of a string. Superseded by preserved phrases. all_caps - If the alphabet in the string is all uppercase, do not modify. all_lowercase - If the alphabet in the string is all lowercase, do not modify. """ # Register template function self.template_funcs["titlecase"] = self.titlecase # Register UI subcommands self._command = ui.Subcommand( "titlecase", help="Apply titlecasing to metadata specified in config.", ) if self.config["auto"].get(bool): if self.config["after_choice"].get(bool): self.import_stages = [self.imported] else: self.register_listener( "trackinfo_received", self.received_info_handler ) self.register_listener( "albuminfo_received", self.received_info_handler ) @cached_property def force_lowercase(self) -> bool: return self.config["force_lowercase"].get(bool) @cached_property def replace(self) -> list[tuple[str, str]]: return self.config["replace"].as_pairs(default_value="") @cached_property def the_artist(self) -> bool: return self.config["the_artist"].get(bool) @cached_property def fields_to_process(self) -> set[str]: fields = set(self.config["fields"].as_str_seq()) self._log.debug(f"fields: {', '.join(fields)}") return fields @cached_property def preserve(self) -> PreservedText: strings = self.config["preserve"].as_str_seq() preserved: PreservedText = {"words": {}, "phrases": {}} for s in strings: if " " in s: preserved["phrases"][s] = re.compile( rf"\b{re.escape(s)}\b", re.IGNORECASE ) else: preserved["words"][s.upper()] = s return preserved @cached_property def separators(self) -> re.Pattern[str] | None: if separators := "".join( dict.fromkeys(self.config["separators"].as_str_seq()) ): return re.compile(rf"(.*?[{re.escape(separators)}]+)(\s*)(?=.)") return None @cached_property def small_first_last(self) -> bool: return self.config["small_first_last"].get(bool) @cached_property def all_caps(self) -> bool: return self.config["all_caps"].get(bool) @cached_property def all_lowercase(self) -> bool: return self.config["all_lowercase"].get(bool) @cached_property def the_artist_regexp(self) -> re.Pattern[str]: return re.compile(r"\bthe\b") def titlecase_callback(self, word, **kwargs) -> str | None: """Callback function for words to preserve case of.""" if preserved_word := self.preserve["words"].get(word.upper(), ""): return preserved_word return None def received_info_handler(self, info: Info): """Calls titlecase fields for AlbumInfo or TrackInfo Processes the tracks field for AlbumInfo """ self.titlecase_fields(info) if isinstance(info, AlbumInfo): for track in info.tracks: self.titlecase_fields(track) def commands(self) -> list[ui.Subcommand]: def func(lib, opts, args): write = ui.should_write() for item in lib.items(args): self._log.info(f"titlecasing {item.title}:") self.titlecase_fields(item) item.store() if write: item.try_write() self._command.func = func return [self._command] def titlecase_fields(self, item: Item | Info) -> None: """Applies titlecase to fields, except those excluded by the default exclusions and the set exclude lists. """ for field in self.fields_to_process: init_field = getattr(item, field, "") if init_field: if isinstance(init_field, list) and isinstance( init_field[0], str ): cased_list: list[str] = [ self.titlecase(i, field) for i in init_field ] if cased_list != init_field: setattr(item, field, cased_list) self._log.debug( f"{field}: {', '.join(init_field)} ->", f"{', '.join(cased_list)}", ) elif isinstance(init_field, str): cased: str = self.titlecase(init_field, field) if cased != init_field: setattr(item, field, cased) self._log.debug(f"{field}: {init_field} -> {cased}") else: self._log.debug(f"{field}: no string present") else: self._log.debug(f"{field}: does not exist on {type(item)}") def titlecase(self, text: str, field: str = "") -> str: """Titlecase the given text.""" # Check we should split this into two substrings. if self.separators: if len(splits := self.separators.findall(text)): split_cased = "".join( [self.titlecase(s[0], field) + s[1] for s in splits] ) # Add on the remaining portion return split_cased + self.titlecase( text[len(split_cased) :], field ) # Check if A-Z is all uppercase or all lowercase if self.all_lowercase and text.islower(): return text elif self.all_caps and text.isupper(): return text # Any necessary replacements go first, mainly punctuation. titlecased = text.lower() if self.force_lowercase else text for pair in self.replace: target, replacement = pair titlecased = titlecased.replace(target, replacement) # General titlecase operation titlecased = titlecase( titlecased, small_first_last=self.small_first_last, callback=self.titlecase_callback, ) # Apply "The Artist" feature if self.the_artist and "artist" in field: titlecased = self.the_artist_regexp.sub("The", titlecased) # More complicated phrase replacements. for phrase, regexp in self.preserve["phrases"].items(): titlecased = regexp.sub(phrase, titlecased) return titlecased def imported(self, session: ImportSession, task: ImportTask) -> None: """Import hook for titlecasing on import.""" for item in task.imported_items(): try: self._log.debug(f"titlecasing {item.title}:") self.titlecase_fields(item) item.store() except Exception as e: self._log.debug(f"titlecasing exception {e}") ================================================ FILE: beetsplug/types.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from confuse import ConfigValueError from beets.dbcore import types from beets.plugins import BeetsPlugin class TypesPlugin(BeetsPlugin): @property def item_types(self): return self._types() @property def album_types(self): return self._types() def _types(self): if not self.config.exists(): return {} mytypes = {} for key, value in self.config.items(): if value.get() == "int": mytypes[key] = types.INTEGER elif value.get() == "float": mytypes[key] = types.FLOAT elif value.get() == "bool": mytypes[key] = types.BOOLEAN elif value.get() == "date": mytypes[key] = types.DATE else: raise ConfigValueError( f"unknown type '{value}' for the '{key}' field" ) return mytypes ================================================ FILE: beetsplug/unimported.py ================================================ # This file is part of beets. # Copyright 2019, Joris Jensen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """ List all files in the library folder which are not listed in the beets library database, including art files """ import os from beets import util from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ __author__ = "https://github.com/MrNuggelz" class Unimported(BeetsPlugin): def __init__(self): super().__init__() self.config.add({"ignore_extensions": [], "ignore_subdirectories": []}) def commands(self): def print_unimported(lib, opts, args): ignore_exts = [ f".{x}".encode() for x in self.config["ignore_extensions"].as_str_seq() ] ignore_dirs = [ os.path.join(lib.directory, x.encode()) for x in self.config["ignore_subdirectories"].as_str_seq() ] in_folder = set() for root, _, files in os.walk(lib.directory): # do not traverse if root is a child of an ignored directory if any(root.startswith(ignored) for ignored in ignore_dirs): continue for file in files: # ignore files with ignored extensions if any(file.endswith(ext) for ext in ignore_exts): continue in_folder.add(os.path.join(root, file)) in_library = {x.path for x in lib.items()} art_files = {x.artpath for x in lib.albums()} for f in in_folder - in_library - art_files: print_(util.displayable_path(f)) unimported = Subcommand( "unimported", help="list all files in the library folder which are not listed" " in the beets library database", ) unimported.func = print_unimported return [unimported] ================================================ FILE: beetsplug/web/__init__.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A Web interface to beets.""" import base64 import json import os import typing as t import flask from flask import jsonify from unidecode import unidecode from werkzeug.routing import BaseConverter, PathConverter import beets.library from beets import ui, util from beets.dbcore.query import PathQuery from beets.plugins import BeetsPlugin # Type checking hacks if t.TYPE_CHECKING: class LibraryCtx(flask.ctx._AppCtxGlobals): lib: beets.library.Library g = LibraryCtx() else: from flask import g # Utilities. def _rep(obj, expand=False): """Get a flat -- i.e., JSON-ish -- representation of a beets Item or Album object. For Albums, `expand` dictates whether tracks are included. """ out = dict(obj) if isinstance(obj, beets.library.Item): if app.config.get("INCLUDE_PATHS", False): out["path"] = util.displayable_path(out["path"]) else: del out["path"] # Filter all bytes attributes and convert them to strings. for key, value in out.items(): if isinstance(out[key], bytes): out[key] = base64.b64encode(value).decode("ascii") # Get the size (in bytes) of the backing file. This is useful # for the Tomahawk resolver API. try: out["size"] = os.path.getsize(util.syspath(obj.path)) except OSError: out["size"] = 0 return out elif isinstance(obj, beets.library.Album): if app.config.get("INCLUDE_PATHS", False): out["artpath"] = util.displayable_path(out["artpath"]) else: del out["artpath"] if expand: out["items"] = [_rep(item) for item in obj.items()] return out def json_generator(items, root, expand=False): """Generator that dumps list of beets Items or Albums as JSON :param root: root key for JSON :param items: list of :class:`Item` or :class:`Album` to dump :param expand: If true every :class:`Album` contains its items in the json representation :returns: generator that yields strings """ yield f'{{"{root}":[' first = True for item in items: if first: first = False else: yield "," yield json.dumps(_rep(item, expand=expand)) yield "]}" def is_expand(): """Returns whether the current request is for an expanded response.""" return flask.request.args.get("expand") is not None def is_delete(): """Returns whether the current delete request should remove the selected files. """ return flask.request.args.get("delete") is not None def get_method(): """Returns the HTTP method of the current request.""" return flask.request.method def resource(name, patchable=False): """Decorates a function to handle RESTful HTTP requests for a resource.""" def make_responder(retriever): def responder(ids): entities = [retriever(id) for id in ids] entities = [entity for entity in entities if entity] if get_method() == "DELETE": if app.config.get("READONLY", True): return flask.abort(405) for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({"deleted": True}), 200) elif get_method() == "PATCH" and patchable: if app.config.get("READONLY", True): return flask.abort(405) for entity in entities: entity.update(flask.request.get_json()) entity.try_sync(True, False) # write, don't move if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), mimetype="application/json", ) elif get_method() == "GET": if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), mimetype="application/json", ) else: return flask.abort(404) else: return flask.abort(405) responder.__name__ = f"get_{name}" return responder return make_responder def resource_query(name, patchable=False): """Decorates a function to handle RESTful HTTP queries for resources.""" def make_responder(query_func): def responder(queries): entities = query_func(queries) if get_method() == "DELETE": if app.config.get("READONLY", True): return flask.abort(405) for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({"deleted": True}), 200) elif get_method() == "PATCH" and patchable: if app.config.get("READONLY", True): return flask.abort(405) for entity in entities: entity.update(flask.request.get_json()) entity.try_sync(True, False) # write, don't move return app.response_class( json_generator(entities, root=name), mimetype="application/json", ) elif get_method() == "GET": return app.response_class( json_generator( entities, root="results", expand=is_expand() ), mimetype="application/json", ) else: return flask.abort(405) responder.__name__ = f"query_{name}" return responder return make_responder def resource_list(name): """Decorates a function to handle RESTful HTTP request for a list of resources. """ def make_responder(list_all): def responder(): return app.response_class( json_generator(list_all(), root=name, expand=is_expand()), mimetype="application/json", ) responder.__name__ = f"all_{name}" return responder return make_responder def _get_unique_table_field_values(model, field, sort_field): """retrieve all unique values belonging to a key from a model""" if field not in model.all_keys() or sort_field not in model.all_keys(): raise KeyError with g.lib.transaction() as tx: rows = tx.query( f"SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}" ) return [row[0] for row in rows] class IdListConverter(BaseConverter): """Converts comma separated lists of ids in urls to integer lists.""" def to_python(self, value): ids = [] for id in value.split(","): try: ids.append(int(id)) except ValueError: pass return ids def to_url(self, value): return ",".join(str(v) for v in value) class QueryConverter(PathConverter): """Converts slash separated lists of queries in the url to string list.""" def to_python(self, value): queries = value.split("/") """Do not do path substitution on regex value tests""" return [ query if "::" in query else query.replace("\\", os.sep) for query in queries ] def to_url(self, value): return "/".join([v.replace(os.sep, "\\") for v in value]) class EverythingConverter(PathConverter): part_isolating = False regex = ".*?" # Flask setup. app = flask.Flask(__name__) app.url_map.converters["idlist"] = IdListConverter app.url_map.converters["query"] = QueryConverter app.url_map.converters["everything"] = EverythingConverter @app.before_request def before_request(): g.lib = app.config["lib"] # Items. @app.route("/item/<idlist:ids>", methods=["GET", "DELETE", "PATCH"]) @resource("items", patchable=True) def get_item(id): return g.lib.get_item(id) @app.route("/item/") @app.route("/item/query/") @resource_list("items") def all_items(): return g.lib.items() @app.route("/item/<int:item_id>/file") def item_file(item_id): item = g.lib.get_item(item_id) item_path = util.syspath(item.path) base_filename = os.path.basename(item_path) try: # Imitate http.server behaviour base_filename.encode("latin-1", "strict") except UnicodeError: safe_filename = unidecode(base_filename) else: safe_filename = base_filename response = flask.send_file( item_path, as_attachment=True, download_name=safe_filename ) return response @app.route("/item/query/<query:queries>", methods=["GET", "DELETE", "PATCH"]) @resource_query("items", patchable=True) def item_query(queries): return g.lib.items(queries) @app.route("/item/path/<everything:path>") def item_at_path(path): query = PathQuery("path", path.encode("utf-8")) item = g.lib.items(query).get() if item: return flask.jsonify(_rep(item)) else: return flask.abort(404) @app.route("/item/values/<string:key>") def item_unique_field_values(key): sort_key = flask.request.args.get("sort_key", key) try: values = _get_unique_table_field_values( beets.library.Item, key, sort_key ) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Albums. @app.route("/album/<idlist:ids>", methods=["GET", "DELETE"]) @resource("albums") def get_album(id): return g.lib.get_album(id) @app.route("/album/") @app.route("/album/query/") @resource_list("albums") def all_albums(): return g.lib.albums() @app.route("/album/query/<query:queries>", methods=["GET", "DELETE"]) @resource_query("albums") def album_query(queries): return g.lib.albums(queries) @app.route("/album/<int:album_id>/art") def album_art(album_id): album = g.lib.get_album(album_id) if album and album.artpath: return flask.send_file(album.artpath.decode()) else: return flask.abort(404) @app.route("/album/values/<string:key>") def album_unique_field_values(key): sort_key = flask.request.args.get("sort_key", key) try: values = _get_unique_table_field_values( beets.library.Album, key, sort_key ) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Artists. @app.route("/artist/") def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") all_artists = [row[0] for row in rows] return flask.jsonify(artist_names=all_artists) # Library information. @app.route("/stats") def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") album_rows = tx.query("SELECT COUNT(*) FROM albums") return flask.jsonify( { "items": item_rows[0][0], "albums": album_rows[0][0], } ) # UI. @app.route("/") def home(): return flask.render_template("index.html") # Plugin hook. class WebPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { "host": "127.0.0.1", "port": 8337, "cors": "", "cors_supports_credentials": False, "reverse_proxy": False, "include_paths": False, "readonly": True, } ) def commands(self): cmd = ui.Subcommand("web", help="start a Web interface") cmd.parser.add_option( "-d", "--debug", action="store_true", default=False, help="debug mode", ) def func(lib, opts, args): args = args if args: self.config["host"] = args.pop(0) if args: self.config["port"] = int(args.pop(0)) app.config["lib"] = lib # Normalizes json output app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False app.config["INCLUDE_PATHS"] = self.config["include_paths"] app.config["READONLY"] = self.config["readonly"] # Enable CORS if required. if self.config["cors"]: self._log.info( "Enabling CORS with origin: {}", self.config["cors"] ) from flask_cors import CORS app.config["CORS_ALLOW_HEADERS"] = "Content-Type" app.config["CORS_RESOURCES"] = { r"/*": {"origins": self.config["cors"].get(str)} } CORS( app, supports_credentials=self.config[ "cors_supports_credentials" ].get(bool), ) # Allow serving behind a reverse proxy if self.config["reverse_proxy"]: app.wsgi_app = ReverseProxied(app.wsgi_app) # Start the web application. app.run( host=self.config["host"].as_str(), port=self.config["port"].get(int), debug=opts.debug, threaded=True, ) cmd.func = func return [cmd] class ReverseProxied: """Wrap the application in this middleware and configure the front-end server to add these headers, to let you quietly bind this to a URL other than / and to an HTTP scheme that is different than what is used locally. In nginx: location /myprefix { proxy_pass http://192.168.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Script-Name /myprefix; } From: http://flask.pocoo.org/snippets/35/ :param app: the WSGI application """ def __init__(self, app): self.app = app def __call__(self, environ, start_response): script_name = environ.get("HTTP_X_SCRIPT_NAME", "") if script_name: environ["SCRIPT_NAME"] = script_name path_info = environ["PATH_INFO"] if path_info.startswith(script_name): environ["PATH_INFO"] = path_info[len(script_name) :] scheme = environ.get("HTTP_X_SCHEME", "") if scheme: environ["wsgi.url_scheme"] = scheme return self.app(environ, start_response) ================================================ FILE: beetsplug/web/static/backbone.js ================================================ // Backbone.js 0.5.3 // (c) 2010 Jeremy Ashkenas, DocumentCloud Inc. // Backbone may be freely distributed under the MIT license. // For all details and documentation: // http://documentcloud.github.com/backbone (function(){ // Initial Setup // ------------- // Save a reference to the global object. var root = this; // Save the previous value of the `Backbone` variable. var previousBackbone = root.Backbone; // The top-level namespace. All public Backbone classes and modules will // be attached to this. Exported for both CommonJS and the browser. var Backbone; if (typeof exports !== 'undefined') { Backbone = exports; } else { Backbone = root.Backbone = {}; } // Current version of the library. Keep in sync with `package.json`. Backbone.VERSION = '0.5.3'; // Require Underscore, if we're on the server, and it's not already present. var _ = root._; if (!_ && (typeof require !== 'undefined')) _ = require('underscore')._; // For Backbone's purposes, jQuery or Zepto owns the `$` variable. var $ = root.jQuery || root.Zepto; // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable // to its previous owner. Returns a reference to this Backbone object. Backbone.noConflict = function() { root.Backbone = previousBackbone; return this; }; // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option will // fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and set a // `X-Http-Method-Override` header. Backbone.emulateHTTP = false; // Turn on `emulateJSON` to support legacy servers that can't deal with direct // `application/json` requests ... will encode the body as // `application/x-www-form-urlencoded` instead and will send the model in a // form param named `model`. Backbone.emulateJSON = false; // Backbone.Events // ----------------- // A module that can be mixed in to *any object* in order to provide it with // custom events. You may `bind` or `unbind` a callback function to an event; // `trigger`-ing an event fires all callbacks in succession. // // var object = {}; // _.extend(object, Backbone.Events); // object.bind('expand', function(){ alert('expanded'); }); // object.trigger('expand'); // Backbone.Events = { // Bind an event, specified by a string name, `ev`, to a `callback` function. // Passing `"all"` will bind the callback to all events fired. bind : function(ev, callback, context) { var calls = this._callbacks || (this._callbacks = {}); var list = calls[ev] || (calls[ev] = []); list.push([callback, context]); return this; }, // Remove one or many callbacks. If `callback` is null, removes all // callbacks for the event. If `ev` is null, removes all bound callbacks // for all events. unbind : function(ev, callback) { var calls; if (!ev) { this._callbacks = {}; } else if (calls = this._callbacks) { if (!callback) { calls[ev] = []; } else { var list = calls[ev]; if (!list) return this; for (var i = 0, l = list.length; i < l; i++) { if (list[i] && callback === list[i][0]) { list[i] = null; break; } } } } return this; }, // Trigger an event, firing all bound callbacks. Callbacks are passed the // same arguments as `trigger` is, apart from the event name. // Listening for `"all"` passes the true event name as the first argument. trigger : function(eventName) { var list, calls, ev, callback, args; var both = 2; if (!(calls = this._callbacks)) return this; while (both--) { ev = both ? eventName : 'all'; if (list = calls[ev]) { for (var i = 0, l = list.length; i < l; i++) { if (!(callback = list[i])) { list.splice(i, 1); i--; l--; } else { args = both ? Array.prototype.slice.call(arguments, 1) : arguments; callback[0].apply(callback[1] || this, args); } } } } return this; } }; // Backbone.Model // -------------- // Create a new model, with defined attributes. A client id (`cid`) // is automatically generated and assigned for you. Backbone.Model = function(attributes, options) { var defaults; attributes || (attributes = {}); if (defaults = this.defaults) { if (_.isFunction(defaults)) defaults = defaults.call(this); attributes = _.extend({}, defaults, attributes); } this.attributes = {}; this._escapedAttributes = {}; this.cid = _.uniqueId('c'); this.set(attributes, {silent : true}); this._changed = false; this._previousAttributes = _.clone(this.attributes); if (options && options.collection) this.collection = options.collection; this.initialize(attributes, options); }; // Attach all inheritable methods to the Model prototype. _.extend(Backbone.Model.prototype, Backbone.Events, { // A snapshot of the model's previous attributes, taken immediately // after the last `"change"` event was fired. _previousAttributes : null, // Has the item been changed since the last `"change"` event? _changed : false, // The default name for the JSON `id` attribute is `"id"`. MongoDB and // CouchDB users may want to set this to `"_id"`. idAttribute : 'id', // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // Return a copy of the model's `attributes` object. toJSON : function() { return _.clone(this.attributes); }, // Get the value of an attribute. get : function(attr) { return this.attributes[attr]; }, // Get the HTML-escaped value of an attribute. escape : function(attr) { var html; if (html = this._escapedAttributes[attr]) return html; var val = this.attributes[attr]; return this._escapedAttributes[attr] = escapeHTML(val == null ? '' : '' + val); }, // Returns `true` if the attribute contains a value that is not null // or undefined. has : function(attr) { return this.attributes[attr] != null; }, // Set a hash of model attributes on the object, firing `"change"` unless you // choose to silence it. set : function(attrs, options) { // Extract attributes and options. options || (options = {}); if (!attrs) return this; if (attrs.attributes) attrs = attrs.attributes; var now = this.attributes, escaped = this._escapedAttributes; // Run validation. if (!options.silent && this.validate && !this._performValidation(attrs, options)) return false; // Check for changes of `id`. if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; // We're about to start triggering change events. var alreadyChanging = this._changing; this._changing = true; // Update attributes. for (var attr in attrs) { var val = attrs[attr]; if (!_.isEqual(now[attr], val)) { now[attr] = val; delete escaped[attr]; this._changed = true; if (!options.silent) this.trigger('change:' + attr, this, val, options); } } // Fire the `"change"` event, if the model has been changed. if (!alreadyChanging && !options.silent && this._changed) this.change(options); this._changing = false; return this; }, // Remove an attribute from the model, firing `"change"` unless you choose // to silence it. `unset` is a noop if the attribute doesn't exist. unset : function(attr, options) { if (!(attr in this.attributes)) return this; options || (options = {}); var value = this.attributes[attr]; // Run validation. var validObj = {}; validObj[attr] = void 0; if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false; // Remove the attribute. delete this.attributes[attr]; delete this._escapedAttributes[attr]; if (attr == this.idAttribute) delete this.id; this._changed = true; if (!options.silent) { this.trigger('change:' + attr, this, void 0, options); this.change(options); } return this; }, // Clear all attributes on the model, firing `"change"` unless you choose // to silence it. clear : function(options) { options || (options = {}); var attr; var old = this.attributes; // Run validation. var validObj = {}; for (attr in old) validObj[attr] = void 0; if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false; this.attributes = {}; this._escapedAttributes = {}; this._changed = true; if (!options.silent) { for (attr in old) { this.trigger('change:' + attr, this, void 0, options); } this.change(options); } return this; }, // Fetch the model from the server. If the server's representation of the // model differs from its current attributes, they will be overridden, // triggering a `"change"` event. fetch : function(options) { options || (options = {}); var model = this; var success = options.success; options.success = function(resp, status, xhr) { if (!model.set(model.parse(resp, xhr), options)) return false; if (success) success(model, resp); }; options.error = wrapError(options.error, model, options); return (this.sync || Backbone.sync).call(this, 'read', this, options); }, // Set a hash of model attributes, and sync the model to the server. // If the server returns an attributes hash that differs, the model's // state will be `set` again. save : function(attrs, options) { options || (options = {}); if (attrs && !this.set(attrs, options)) return false; var model = this; var success = options.success; options.success = function(resp, status, xhr) { if (!model.set(model.parse(resp, xhr), options)) return false; if (success) success(model, resp, xhr); }; options.error = wrapError(options.error, model, options); var method = this.isNew() ? 'create' : 'update'; return (this.sync || Backbone.sync).call(this, method, this, options); }, // Destroy this model on the server if it was already persisted. Upon success, the model is removed // from its collection, if it has one. destroy : function(options) { options || (options = {}); if (this.isNew()) return this.trigger('destroy', this, this.collection, options); var model = this; var success = options.success; options.success = function(resp) { model.trigger('destroy', model, model.collection, options); if (success) success(model, resp); }; options.error = wrapError(options.error, model, options); return (this.sync || Backbone.sync).call(this, 'delete', this, options); }, // Default URL for the model's representation on the server -- if you're // using Backbone's restful methods, override this to change the endpoint // that will be called. url : function() { var base = getUrl(this.collection) || this.urlRoot || urlError(); if (this.isNew()) return base; return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id); }, // **parse** converts a response into the hash of attributes to be `set` on // the model. The default implementation is just to pass the response along. parse : function(resp, xhr) { return resp; }, // Create a new model with identical attributes to this one. clone : function() { return new this.constructor(this); }, // A model is new if it has never been saved to the server, and lacks an id. isNew : function() { return this.id == null; }, // Call this method to manually fire a `change` event for this model. // Calling this will cause all objects observing the model to update. change : function(options) { this.trigger('change', this, options); this._previousAttributes = _.clone(this.attributes); this._changed = false; }, // Determine if the model has changed since the last `"change"` event. // If you specify an attribute name, determine if that attribute has changed. hasChanged : function(attr) { if (attr) return this._previousAttributes[attr] != this.attributes[attr]; return this._changed; }, // Return an object containing all the attributes that have changed, or false // if there are no changed attributes. Useful for determining what parts of a // view need to be updated and/or what attributes need to be persisted to // the server. changedAttributes : function(now) { now || (now = this.attributes); var old = this._previousAttributes; var changed = false; for (var attr in now) { if (!_.isEqual(old[attr], now[attr])) { changed = changed || {}; changed[attr] = now[attr]; } } return changed; }, // Get the previous value of an attribute, recorded at the time the last // `"change"` event was fired. previous : function(attr) { if (!attr || !this._previousAttributes) return null; return this._previousAttributes[attr]; }, // Get all of the attributes of the model at the time of the previous // `"change"` event. previousAttributes : function() { return _.clone(this._previousAttributes); }, // Run validation against a set of incoming attributes, returning `true` // if all is well. If a specific `error` callback has been passed, // call that instead of firing the general `"error"` event. _performValidation : function(attrs, options) { var error = this.validate(attrs); if (error) { if (options.error) { options.error(this, error, options); } else { this.trigger('error', this, error, options); } return false; } return true; } }); // Backbone.Collection // ------------------- // Provides a standard collection class for our sets of models, ordered // or unordered. If a `comparator` is specified, the Collection will maintain // its models in sort order, as they're added and removed. Backbone.Collection = function(models, options) { options || (options = {}); if (options.comparator) this.comparator = options.comparator; _.bindAll(this, '_onModelEvent', '_removeReference'); this._reset(); if (models) this.reset(models, {silent: true}); this.initialize.apply(this, arguments); }; // Define the Collection's inheritable methods. _.extend(Backbone.Collection.prototype, Backbone.Events, { // The default model for a collection is just a **Backbone.Model**. // This should be overridden in most cases. model : Backbone.Model, // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // The JSON representation of a Collection is an array of the // models' attributes. toJSON : function() { return this.map(function(model){ return model.toJSON(); }); }, // Add a model, or list of models to the set. Pass **silent** to avoid // firing the `added` event for every new model. add : function(models, options) { if (_.isArray(models)) { for (var i = 0, l = models.length; i < l; i++) { this._add(models[i], options); } } else { this._add(models, options); } return this; }, // Remove a model, or a list of models from the set. Pass silent to avoid // firing the `removed` event for every model removed. remove : function(models, options) { if (_.isArray(models)) { for (var i = 0, l = models.length; i < l; i++) { this._remove(models[i], options); } } else { this._remove(models, options); } return this; }, // Get a model from the set by id. get : function(id) { if (id == null) return null; return this._byId[id.id != null ? id.id : id]; }, // Get a model from the set by client id. getByCid : function(cid) { return cid && this._byCid[cid.cid || cid]; }, // Get the model at the given index. at: function(index) { return this.models[index]; }, // Force the collection to re-sort itself. You don't need to call this under normal // circumstances, as the set will maintain sort order as each item is added. sort : function(options) { options || (options = {}); if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); this.models = this.sortBy(this.comparator); if (!options.silent) this.trigger('reset', this, options); return this; }, // Pluck an attribute from each model in the collection. pluck : function(attr) { return _.map(this.models, function(model){ return model.get(attr); }); }, // When you have more items than you want to add or remove individually, // you can reset the entire set with a new list of models, without firing // any `added` or `removed` events. Fires `reset` when finished. reset : function(models, options) { models || (models = []); options || (options = {}); this.each(this._removeReference); this._reset(); this.add(models, {silent: true}); if (!options.silent) this.trigger('reset', this, options); return this; }, // Fetch the default set of models for this collection, resetting the // collection when they arrive. If `add: true` is passed, appends the // models to the collection instead of resetting. fetch : function(options) { options || (options = {}); var collection = this; var success = options.success; options.success = function(resp, status, xhr) { collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options); if (success) success(collection, resp); }; options.error = wrapError(options.error, collection, options); return (this.sync || Backbone.sync).call(this, 'read', this, options); }, // Create a new instance of a model in this collection. After the model // has been created on the server, it will be added to the collection. // Returns the model, or 'false' if validation on a new model fails. create : function(model, options) { var coll = this; options || (options = {}); model = this._prepareModel(model, options); if (!model) return false; var success = options.success; options.success = function(nextModel, resp, xhr) { coll.add(nextModel, options); if (success) success(nextModel, resp, xhr); }; model.save(null, options); return model; }, // **parse** converts a response into a list of models to be added to the // collection. The default implementation is just to pass it through. parse : function(resp, xhr) { return resp; }, // Proxy to _'s chain. Can't be proxied the same way the rest of the // underscore methods are proxied because it relies on the underscore // constructor. chain: function () { return _(this.models).chain(); }, // Reset all internal state. Called when the collection is reset. _reset : function(options) { this.length = 0; this.models = []; this._byId = {}; this._byCid = {}; }, // Prepare a model to be added to this collection _prepareModel: function(model, options) { if (!(model instanceof Backbone.Model)) { var attrs = model; model = new this.model(attrs, {collection: this}); if (model.validate && !model._performValidation(attrs, options)) model = false; } else if (!model.collection) { model.collection = this; } return model; }, // Internal implementation of adding a single model to the set, updating // hash indexes for `id` and `cid` lookups. // Returns the model, or 'false' if validation on a new model fails. _add : function(model, options) { options || (options = {}); model = this._prepareModel(model, options); if (!model) return false; var already = this.getByCid(model); if (already) throw new Error(["Can't add the same model to a set twice", already.id]); this._byId[model.id] = model; this._byCid[model.cid] = model; var index = options.at != null ? options.at : this.comparator ? this.sortedIndex(model, this.comparator) : this.length; this.models.splice(index, 0, model); model.bind('all', this._onModelEvent); this.length++; if (!options.silent) model.trigger('add', model, this, options); return model; }, // Internal implementation of removing a single model from the set, updating // hash indexes for `id` and `cid` lookups. _remove : function(model, options) { options || (options = {}); model = this.getByCid(model) || this.get(model); if (!model) return null; delete this._byId[model.id]; delete this._byCid[model.cid]; this.models.splice(this.indexOf(model), 1); this.length--; if (!options.silent) model.trigger('remove', model, this, options); this._removeReference(model); return model; }, // Internal method to remove a model's ties to a collection. _removeReference : function(model) { if (this == model.collection) { delete model.collection; } model.unbind('all', this._onModelEvent); }, // Internal method called every time a model in the set fires an event. // Sets need to update their indexes when models change ids. All other // events simply proxy through. "add" and "remove" events that originate // in other collections are ignored. _onModelEvent : function(ev, model, collection, options) { if ((ev == 'add' || ev == 'remove') && collection != this) return; if (ev == 'destroy') { this._remove(model, options); } if (model && ev === 'change:' + model.idAttribute) { delete this._byId[model.previous(model.idAttribute)]; this._byId[model.id] = model; } this.trigger.apply(this, arguments); } }); // Underscore methods that we want to implement on the Collection. var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', 'toArray', 'size', 'first', 'rest', 'last', 'without', 'indexOf', 'lastIndexOf', 'isEmpty', 'groupBy']; // Mix in each Underscore method as a proxy to `Collection#models`. _.each(methods, function(method) { Backbone.Collection.prototype[method] = function() { return _[method].apply(_, [this.models].concat(_.toArray(arguments))); }; }); // Backbone.Router // ------------------- // Routers map faux-URLs to actions, and fire events when routes are // matched. Creating a new one sets its `routes` hash, if not set statically. Backbone.Router = function(options) { options || (options = {}); if (options.routes) this.routes = options.routes; this._bindRoutes(); this.initialize.apply(this, arguments); }; // Cached regular expressions for matching named param parts and splatted // parts of route strings. var namedParam = /:([\w\d]+)/g; var splatParam = /\*([\w\d]+)/g; var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; // Set up all inheritable **Backbone.Router** properties and methods. _.extend(Backbone.Router.prototype, Backbone.Events, { // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // Manually bind a single named route to a callback. For example: // // this.route('search/:query/p:num', 'search', function(query, num) { // ... // }); // route : function(route, name, callback) { Backbone.history || (Backbone.history = new Backbone.History); if (!_.isRegExp(route)) route = this._routeToRegExp(route); Backbone.history.route(route, _.bind(function(fragment) { var args = this._extractParameters(route, fragment); callback.apply(this, args); this.trigger.apply(this, ['route:' + name].concat(args)); }, this)); }, // Simple proxy to `Backbone.history` to save a fragment into the history. navigate : function(fragment, triggerRoute) { Backbone.history.navigate(fragment, triggerRoute); }, // Bind all defined routes to `Backbone.history`. We have to reverse the // order of the routes here to support behavior where the most general // routes can be defined at the bottom of the route map. _bindRoutes : function() { if (!this.routes) return; var routes = []; for (var route in this.routes) { routes.unshift([route, this.routes[route]]); } for (var i = 0, l = routes.length; i < l; i++) { this.route(routes[i][0], routes[i][1], this[routes[i][1]]); } }, // Convert a route string into a regular expression, suitable for matching // against the current location hash. _routeToRegExp : function(route) { route = route.replace(escapeRegExp, "\\$&") .replace(namedParam, "([^\/]*)") .replace(splatParam, "(.*?)"); return new RegExp('^' + route + '$'); }, // Given a route, and a URL fragment that it matches, return the array of // extracted parameters. _extractParameters : function(route, fragment) { return route.exec(fragment).slice(1); } }); // Backbone.History // ---------------- // Handles cross-browser history management, based on URL fragments. If the // browser does not support `onhashchange`, falls back to polling. Backbone.History = function() { this.handlers = []; _.bindAll(this, 'checkUrl'); }; // Cached regex for cleaning hashes. var hashStrip = /^#*/; // Cached regex for detecting MSIE. var isExplorer = /msie [\w.]+/; // Has the history handling already been started? var historyStarted = false; // Set up all inheritable **Backbone.History** properties and methods. _.extend(Backbone.History.prototype, { // The default interval to poll for hash changes, if necessary, is // twenty times a second. interval: 50, // Get the cross-browser normalized URL fragment, either from the URL, // the hash, or the override. getFragment : function(fragment, forcePushState) { if (fragment == null) { if (this._hasPushState || forcePushState) { fragment = window.location.pathname; var search = window.location.search; if (search) fragment += search; if (fragment.indexOf(this.options.root) == 0) fragment = fragment.substr(this.options.root.length); } else { fragment = window.location.hash; } } return decodeURIComponent(fragment.replace(hashStrip, '')); }, // Start the hash change handling, returning `true` if the current URL matches // an existing route, and `false` otherwise. start : function(options) { // Figure out the initial configuration. Do we need an iframe? // Is pushState desired ... is it available? if (historyStarted) throw new Error("Backbone.history has already been started"); this.options = _.extend({}, {root: '/'}, this.options, options); this._wantsPushState = !!this.options.pushState; this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState); var fragment = this.getFragment(); var docMode = document.documentMode; var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); if (oldIE) { this.iframe = $('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow; this.navigate(fragment); } // Depending on whether we're using pushState or hashes, and whether // 'onhashchange' is supported, determine how we check the URL state. if (this._hasPushState) { $(window).bind('popstate', this.checkUrl); } else if ('onhashchange' in window && !oldIE) { $(window).bind('hashchange', this.checkUrl); } else { setInterval(this.checkUrl, this.interval); } // Determine if we need to change the base url, for a pushState link // opened by a non-pushState browser. this.fragment = fragment; historyStarted = true; var loc = window.location; var atRoot = loc.pathname == this.options.root; if (this._wantsPushState && !this._hasPushState && !atRoot) { this.fragment = this.getFragment(null, true); window.location.replace(this.options.root + '#' + this.fragment); // Return immediately as browser will do redirect to new url return true; } else if (this._wantsPushState && this._hasPushState && atRoot && loc.hash) { this.fragment = loc.hash.replace(hashStrip, ''); window.history.replaceState({}, document.title, loc.protocol + '//' + loc.host + this.options.root + this.fragment); } if (!this.options.silent) { return this.loadUrl(); } }, // Add a route to be tested when the fragment changes. Routes added later may // override previous routes. route : function(route, callback) { this.handlers.unshift({route : route, callback : callback}); }, // Checks the current URL to see if it has changed, and if it has, // calls `loadUrl`, normalizing across the hidden iframe. checkUrl : function(e) { var current = this.getFragment(); if (current == this.fragment && this.iframe) current = this.getFragment(this.iframe.location.hash); if (current == this.fragment || current == decodeURIComponent(this.fragment)) return false; if (this.iframe) this.navigate(current); this.loadUrl() || this.loadUrl(window.location.hash); }, // Attempt to load the current URL fragment. If a route succeeds with a // match, returns `true`. If no defined routes matches the fragment, // returns `false`. loadUrl : function(fragmentOverride) { var fragment = this.fragment = this.getFragment(fragmentOverride); var matched = _.any(this.handlers, function(handler) { if (handler.route.test(fragment)) { handler.callback(fragment); return true; } }); return matched; }, // Save a fragment into the hash history. You are responsible for properly // URL-encoding the fragment in advance. This does not trigger // a `hashchange` event. navigate : function(fragment, triggerRoute) { var frag = (fragment || '').replace(hashStrip, ''); if (this.fragment == frag || this.fragment == decodeURIComponent(frag)) return; if (this._hasPushState) { var loc = window.location; if (frag.indexOf(this.options.root) != 0) frag = this.options.root + frag; this.fragment = frag; window.history.pushState({}, document.title, loc.protocol + '//' + loc.host + frag); } else { window.location.hash = this.fragment = frag; if (this.iframe && (frag != this.getFragment(this.iframe.location.hash))) { this.iframe.document.open().close(); this.iframe.location.hash = frag; } } if (triggerRoute) this.loadUrl(fragment); } }); // Backbone.View // ------------- // Creating a Backbone.View creates its initial element outside of the DOM, // if an existing element is not provided... Backbone.View = function(options) { this.cid = _.uniqueId('view'); this._configure(options || {}); this._ensureElement(); this.delegateEvents(); this.initialize.apply(this, arguments); }; // Element lookup, scoped to DOM elements within the current view. // This should be preferred to global lookups, if you're dealing with // a specific view. var selectorDelegate = function(selector) { return $(selector, this.el); }; // Cached regex to split keys for `delegate`. var eventSplitter = /^(\S+)\s*(.*)$/; // List of view options to be merged as properties. var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName']; // Set up all inheritable **Backbone.View** properties and methods. _.extend(Backbone.View.prototype, Backbone.Events, { // The default `tagName` of a View's element is `"div"`. tagName : 'div', // Attach the `selectorDelegate` function as the `$` property. $ : selectorDelegate, // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // **render** is the core function that your view should override, in order // to populate its element (`this.el`), with the appropriate HTML. The // convention is for **render** to always return `this`. render : function() { return this; }, // Remove this view from the DOM. Note that the view isn't present in the // DOM by default, so calling this method may be a no-op. remove : function() { $(this.el).remove(); return this; }, // For small amounts of DOM Elements, where a full-blown template isn't // needed, use **make** to manufacture elements, one at a time. // // var el = this.make('li', {'class': 'row'}, this.model.escape('title')); // make : function(tagName, attributes, content) { var el = document.createElement(tagName); if (attributes) $(el).attr(attributes); if (content) $(el).html(content); return el; }, // Set callbacks, where `this.callbacks` is a hash of // // *{"event selector": "callback"}* // // { // 'mousedown .title': 'edit', // 'click .button': 'save' // } // // pairs. Callbacks will be bound to the view, with `this` set properly. // Uses event delegation for efficiency. // Omitting the selector binds the event to `this.el`. // This only works for delegate-able events: not `focus`, `blur`, and // not `change`, `submit`, and `reset` in Internet Explorer. delegateEvents : function(events) { if (!(events || (events = this.events))) return; if (_.isFunction(events)) events = events.call(this); $(this.el).unbind('.delegateEvents' + this.cid); for (var key in events) { var method = this[events[key]]; if (!method) throw new Error('Event "' + events[key] + '" does not exist'); var match = key.match(eventSplitter); var eventName = match[1], selector = match[2]; method = _.bind(method, this); eventName += '.delegateEvents' + this.cid; if (selector === '') { $(this.el).bind(eventName, method); } else { $(this.el).delegate(selector, eventName, method); } } }, // Performs the initial configuration of a View with a set of options. // Keys with special meaning *(model, collection, id, className)*, are // attached directly to the view. _configure : function(options) { if (this.options) options = _.extend({}, this.options, options); for (var i = 0, l = viewOptions.length; i < l; i++) { var attr = viewOptions[i]; if (options[attr]) this[attr] = options[attr]; } this.options = options; }, // Ensure that the View has a DOM element to render into. // If `this.el` is a string, pass it through `$()`, take the first // matching element, and re-assign it to `el`. Otherwise, create // an element from the `id`, `className` and `tagName` properties. _ensureElement : function() { if (!this.el) { var attrs = this.attributes || {}; if (this.id) attrs.id = this.id; if (this.className) attrs['class'] = this.className; this.el = this.make(this.tagName, attrs); } else if (_.isString(this.el)) { this.el = $(this.el).get(0); } } }); // The self-propagating extend function that Backbone classes use. var extend = function (protoProps, classProps) { var child = inherits(this, protoProps, classProps); child.extend = this.extend; return child; }; // Set up inheritance for the model, collection, and view. Backbone.Model.extend = Backbone.Collection.extend = Backbone.Router.extend = Backbone.View.extend = extend; // Map from CRUD to HTTP for our default `Backbone.sync` implementation. var methodMap = { 'create': 'POST', 'update': 'PUT', 'delete': 'DELETE', 'read' : 'GET' }; // Backbone.sync // ------------- // Override this function to change the manner in which Backbone persists // models to the server. You will be passed the type of request, and the // model in question. By default, uses makes a RESTful Ajax request // to the model's `url()`. Some possible customizations could be: // // * Use `setTimeout` to batch rapid-fire updates into a single request. // * Send up the models as XML instead of JSON. // * Persist models via WebSockets instead of Ajax. // // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests // as `POST`, with a `_method` parameter containing the true HTTP method, // as well as all requests with the body as `application/x-www-form-urlencoded` instead of // `application/json` with the model in a param named `model`. // Useful when interfacing with server-side languages like **PHP** that make // it difficult to read the body of `PUT` requests. Backbone.sync = function(method, model, options) { var type = methodMap[method]; // Default JSON-request options. var params = _.extend({ type: type, dataType: 'json' }, options); // Ensure that we have a URL. if (!params.url) { params.url = getUrl(model) || urlError(); } // Ensure that we have the appropriate request data. if (!params.data && model && (method == 'create' || method == 'update')) { params.contentType = 'application/json'; params.data = JSON.stringify(model.toJSON()); } // For older servers, emulate JSON by encoding the request into an HTML-form. if (Backbone.emulateJSON) { params.contentType = 'application/x-www-form-urlencoded'; params.data = params.data ? {model : params.data} : {}; } // For older servers, emulate HTTP by mimicking the HTTP method with `_method` // And an `X-HTTP-Method-Override` header. if (Backbone.emulateHTTP) { if (type === 'PUT' || type === 'DELETE') { if (Backbone.emulateJSON) params.data._method = type; params.type = 'POST'; params.beforeSend = function(xhr) { xhr.setRequestHeader('X-HTTP-Method-Override', type); }; } } // Don't process data on a non-GET request. if (params.type !== 'GET' && !Backbone.emulateJSON) { params.processData = false; } // Make the request. return $.ajax(params); }; // Helpers // ------- // Shared empty constructor function to aid in prototype-chain creation. var ctor = function(){}; // Helper function to correctly set up the prototype chain, for subclasses. // Similar to `goog.inherits`, but uses a hash of prototype properties and // class properties to be extended. var inherits = function(parent, protoProps, staticProps) { var child; // The constructor function for the new subclass is either defined by you // (the "constructor" property in your `extend` definition), or defaulted // by us to simply call `super()`. if (protoProps && protoProps.hasOwnProperty('constructor')) { child = protoProps.constructor; } else { child = function(){ return parent.apply(this, arguments); }; } // Inherit class (static) properties from parent. _.extend(child, parent); // Set the prototype chain to inherit from `parent`, without calling // `parent`'s constructor function. ctor.prototype = parent.prototype; child.prototype = new ctor(); // Add prototype properties (instance properties) to the subclass, // if supplied. if (protoProps) _.extend(child.prototype, protoProps); // Add static properties to the constructor function, if supplied. if (staticProps) _.extend(child, staticProps); // Correctly set child's `prototype.constructor`. child.prototype.constructor = child; // Set a convenience property in case the parent's prototype is needed later. child.__super__ = parent.prototype; return child; }; // Helper function to get a URL from a Model or Collection as a property // or as a function. var getUrl = function(object) { if (!(object && object.url)) return null; return _.isFunction(object.url) ? object.url() : object.url; }; // Throw an error when a URL is needed, and none is supplied. var urlError = function() { throw new Error('A "url" property or function must be specified'); }; // Wrap an optional error callback with a fallback error event. var wrapError = function(onError, model, options) { return function(resp) { if (onError) { onError(model, resp, options); } else { model.trigger('error', model, resp, options); } }; }; // Helper function to escape a string for HTML rendering. var escapeHTML = function(string) { return string.replace(/&(?!\w+;|#\d+;|#x[\da-f]+;)/gi, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); }; }).call(this); ================================================ FILE: beetsplug/web/static/beets.css ================================================ body { font-family: Helvetica, Arial, sans-serif; } #header { position: fixed; left: 0; right: 0; top: 0; height: 36px; color: white; cursor: default; /* shadowy border */ box-shadow: 0 0 20px #999; -webkit-box-shadow: 0 0 20px #999; -moz-box-shadow: 0 0 20px #999; /* background gradient */ background: #0e0e0e; background: -moz-linear-gradient(top, #6b6b6b 0%, #0e0e0e 100%); background: -webkit-linear-gradient(top, #6b6b6b 0%,#0e0e0e 100%); } #header h1 { font-size: 1.1em; font-weight: bold; color: white; margin: 0.35em; float: left; } #entities { width: 17em; position: fixed; top: 36px; left: 0; bottom: 0; margin: 0; z-index: 1; background: #dde4eb; /* shadowy border */ box-shadow: 0 0 20px #666; -webkit-box-shadow: 0 0 20px #666; -moz-box-shadow: 0 0 20px #666; } #queryForm { display: block; text-align: center; margin: 0.25em 0; } #query { width: 95%; font-size: 1em; } #entities ul { width: 17em; position: fixed; top: 36px; left: 0; bottom: 0; margin: 2.2em 0 0 0; padding: 0; overflow-y: auto; overflow-x: hidden; } #entities ul li { list-style: none; padding: 4px 8px; margin: 0; cursor: default; } #entities ul li.selected { background: #7abcff; background: -moz-linear-gradient(top, #7abcff 0%, #60abf8 44%, #4096ee 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#7abcff), color-stop(44%,#60abf8), color-stop(100%,#4096ee)); color: white; } #entities ul li .playing { margin-left: 5px; font-size: 0.9em; } #main-detail, #extra-detail { position: fixed; left: 17em; margin: 1.0em 0 0 1.5em; } #main-detail { top: 36px; height: 98px; } #main-detail .artist, #main-detail .album, #main-detail .title { display: block; } #main-detail .title { font-size: 1.3em; font-weight: bold; } #main-detail .albumtitle { font-style: italic; } #extra-detail { overflow-x: hidden; overflow-y: auto; top: 134px; bottom: 0; right: 0; } /*Fix for correctly displaying line breaks in lyrics*/ #extra-detail .lyrics { white-space: pre-wrap; } #extra-detail dl dt, #extra-detail dl dd { list-style: none; margin: 0; padding: 0; } #extra-detail dl dt { width: 10em; float: left; text-align: right; font-weight: bold; clear: both; } #extra-detail dl dd { margin-left: 10.5em; } #player { float: left; width: 150px; height: 36px; } #player .play, #player .pause, #player .disabled { -webkit-appearance: none; font-size: 1em; font-family: Helvetica, Arial, sans-serif; background: none; border: none; color: white; padding: 5px; margin: 0; text-align: center; width: 36px; height: 36px; } #player .disabled { color: #666; } ================================================ FILE: beetsplug/web/static/beets.js ================================================ // Format times as minutes and seconds. var timeFormat = function(secs) { if (secs == undefined || isNaN(secs)) { return '0:00'; } secs = Math.round(secs); var mins = '' + Math.floor(secs / 60); secs = '' + (secs % 60); if (secs.length < 2) { secs = '0' + secs; } return mins + ':' + secs; } // jQuery extension encapsulating event hookups for audio element controls. $.fn.player = function(debug) { // Selected element should contain an HTML5 Audio element. var audio = $('audio', this).get(0); // Control elements that may be present, identified by class. var playBtn = $('.play', this); var pauseBtn = $('.pause', this); var disabledInd = $('.disabled', this); var timesEl = $('.times', this); var curTimeEl = $('.currentTime', this); var totalTimeEl = $('.totalTime', this); var sliderPlayedEl = $('.slider .played', this); var sliderLoadedEl = $('.slider .loaded', this); // Button events. playBtn.click(function() { audio.play(); }); pauseBtn.click(function(ev) { audio.pause(); }); // Utilities. var timePercent = function(cur, total) { if (cur == undefined || isNaN(cur) || total == undefined || isNaN(total) || total == 0) { return 0; } var ratio = cur / total; if (ratio > 1.0) { ratio = 1.0; } return (Math.round(ratio * 10000) / 100) + '%'; } // Event helpers. var dbg = function(msg) { if (debug) console.log(msg); } var showState = function() { if (audio.duration == undefined || isNaN(audio.duration)) { playBtn.hide(); pauseBtn.hide(); disabledInd.show(); timesEl.hide(); } else if (audio.paused) { playBtn.show(); pauseBtn.hide(); disabledInd.hide(); timesEl.show(); } else { playBtn.hide(); pauseBtn.show(); disabledInd.hide(); timesEl.show(); } } var showTimes = function() { curTimeEl.text(timeFormat(audio.currentTime)); totalTimeEl.text(timeFormat(audio.duration)); sliderPlayedEl.css('width', timePercent(audio.currentTime, audio.duration)); // last time buffered var bufferEnd = 0; for (var i = 0; i < audio.buffered.length; ++i) { if (audio.buffered.end(i) > bufferEnd) bufferEnd = audio.buffered.end(i); } sliderLoadedEl.css('width', timePercent(bufferEnd, audio.duration)); } // Initialize controls. showState(); showTimes(); // Bind events. $('audio', this).bind({ playing: function() { dbg('playing'); showState(); }, pause: function() { dbg('pause'); showState(); }, ended: function() { dbg('ended'); showState(); }, progress: function() { dbg('progress ' + audio.buffered); }, timeupdate: function() { dbg('timeupdate ' + audio.currentTime); showTimes(); }, durationchange: function() { dbg('durationchange ' + audio.duration); showState(); showTimes(); }, loadeddata: function() { dbg('loadeddata'); }, loadedmetadata: function() { dbg('loadedmetadata'); } }); } // Simple selection disable for jQuery. // Cut-and-paste from: // https://stackoverflow.com/questions/2700000 $.fn.disableSelection = function() { $(this).attr('unselectable', 'on') .css('-moz-user-select', 'none') .each(function() { this.onselectstart = function() { return false; }; }); }; $(function() { // Routes. var BeetsRouter = Backbone.Router.extend({ routes: { "item/query/:query": "itemQuery", }, itemQuery: function(query) { var queryURL = query.split(/\s+/).map(encodeURIComponent).join('/'); $.getJSON('item/query/' + queryURL, function(data) { var models = _.map( data['results'], function(d) { return new Item(d); } ); var results = new Items(models); app.showItems(results); }); } }); var router = new BeetsRouter(); // Model. var Item = Backbone.Model.extend({ urlRoot: 'item' }); var Items = Backbone.Collection.extend({ model: Item }); // Item views. var ItemEntryView = Backbone.View.extend({ tagName: "li", template: _.template($('#item-entry-template').html()), events: { 'click': 'select', 'dblclick': 'play' }, initialize: function() { this.playing = false; }, render: function() { $(this.el).html(this.template(this.model.toJSON())); this.setPlaying(this.playing); return this; }, select: function() { app.selectItem(this); }, play: function() { app.playItem(this.model); }, setPlaying: function(val) { this.playing = val; if (val) this.$('.playing').show(); else this.$('.playing').hide(); } }); //Holds Title, Artist, Album etc. var ItemMainDetailView = Backbone.View.extend({ tagName: "div", template: _.template($('#item-main-detail-template').html()), events: { 'click .play': 'play', }, render: function() { $(this.el).html(this.template(this.model.toJSON())); return this; }, play: function() { app.playItem(this.model); } }); // Holds Track no., Format, MusicBrainz link, Lyrics, Comments etc. var ItemExtraDetailView = Backbone.View.extend({ tagName: "div", template: _.template($('#item-extra-detail-template').html()), render: function() { $(this.el).html(this.template(this.model.toJSON())); return this; } }); // Main app view. var AppView = Backbone.View.extend({ el: $('body'), events: { 'submit #queryForm': 'querySubmit', }, querySubmit: function(ev) { ev.preventDefault(); router.navigate('item/query/' + encodeURIComponent($('#query').val()), true); }, initialize: function() { this.playingItem = null; this.shownItems = null; // Not sure why these events won't bind automatically. this.$('audio').bind({ 'play': _.bind(this.audioPlay, this), 'pause': _.bind(this.audioPause, this), 'ended': _.bind(this.audioEnded, this) }); if ("mediaSession" in navigator) { navigator.mediaSession.setActionHandler("nexttrack", () => { this.playNext(); }); } }, showItems: function(items) { this.shownItems = items; $('#results').empty(); items.each(function(item) { var view = new ItemEntryView({model: item}); item.entryView = view; $('#results').append(view.render().el); }); }, selectItem: function(view) { // Mark row as selected. $('#results li').removeClass("selected"); $(view.el).addClass("selected"); // Show main and extra detail. var mainDetailView = new ItemMainDetailView({model: view.model}); $('#main-detail').empty().append(mainDetailView.render().el); var extraDetailView = new ItemExtraDetailView({model: view.model}); $('#extra-detail').empty().append(extraDetailView.render().el); }, playItem: function(item) { var url = 'item/' + item.get('id') + '/file'; $('#player audio').attr('src', url); $('#player audio').get(0).play().then(() => { this.updateMediaSession(item); }); if (this.playingItem != null) { this.playingItem.entryView.setPlaying(false); } item.entryView.setPlaying(true); this.playingItem = item; }, updateMediaSession: function (item) { if ("mediaSession" in navigator) { const album_id = item.get("album_id"); const album_art_url = "album/" + album_id + "/art"; navigator.mediaSession.metadata = new MediaMetadata({ title: item.get("title"), artist: item.get("artist"), album: item.get("album"), artwork: [ { src: album_art_url, sizes: "96x96" }, { src: album_art_url, sizes: "128x128" }, { src: album_art_url, sizes: "192x192" }, { src: album_art_url, sizes: "256x256" }, { src: album_art_url, sizes: "384x384" }, { src: album_art_url, sizes: "512x512" }, ], }); } }, audioPause: function() { this.playingItem.entryView.setPlaying(false); }, audioPlay: function() { if (this.playingItem != null) this.playingItem.entryView.setPlaying(true); }, audioEnded: function() { this.playingItem.entryView.setPlaying(false); this.playNext(); }, playNext: function(){ // Try to play the next track. var idx = this.shownItems.indexOf(this.playingItem); if (idx == -1) { // Not in current list. return; } var nextIdx = idx + 1; if (nextIdx >= this.shownItems.size()) { // End of list. return; } this.playItem(this.shownItems.at(nextIdx)); } }); var app = new AppView(); // App setup. Backbone.history.start({pushState: false}); // Disable selection on UI elements. $('#entities ul').disableSelection(); $('#header').disableSelection(); // Audio player setup. $('#player').player(); }); ================================================ FILE: beetsplug/web/static/jquery.js ================================================ /*! * jQuery JavaScript Library v1.7.1 * http://jquery.com/ * * Copyright 2016, John Resig * Dual licensed under the MIT or GPL Version 2 licenses. * http://jquery.org/license * * Includes Sizzle.js * http://sizzlejs.com/ * Copyright 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * * Date: Mon Nov 21 21:11:03 2011 -0500 */ (function( window, undefined ) { // Use the correct document accordingly with window argument (sandbox) var document = window.document, navigator = window.navigator, location = window.location; var jQuery = (function() { // Define a local copy of jQuery var jQuery = function( selector, context ) { // The jQuery object is actually just the init constructor 'enhanced' return new jQuery.fn.init( selector, context, rootjQuery ); }, // Map over jQuery in case of overwrite _jQuery = window.jQuery, // Map over the $ in case of overwrite _$ = window.$, // A central reference to the root jQuery(document) rootjQuery, // A simple way to check for HTML strings or ID strings // Prioritize #id over <tag> to avoid XSS via location.hash (#9521) quickExpr = /^(?:[^#<]*(<[\w\W]+>)[^>]*$|#([\w\-]*)$)/, // Check if a string has a non-whitespace character in it rnotwhite = /\S/, // Used for trimming whitespace trimLeft = /^\s+/, trimRight = /\s+$/, // Match a standalone tag rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>)?$/, // JSON RegExp rvalidchars = /^[\],:{}\s]*$/, rvalidescape = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, rvalidtokens = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g, // Useragent RegExp rwebkit = /(webkit)[ \/]([\w.]+)/, ropera = /(opera)(?:.*version)?[ \/]([\w.]+)/, rmsie = /(msie) ([\w.]+)/, rmozilla = /(mozilla)(?:.*? rv:([\w.]+))?/, // Matches dashed string for camelizing rdashAlpha = /-([a-z]|[0-9])/ig, rmsPrefix = /^-ms-/, // Used by jQuery.camelCase as callback to replace() fcamelCase = function( all, letter ) { return ( letter + "" ).toUpperCase(); }, // Keep a UserAgent string for use with jQuery.browser userAgent = navigator.userAgent, // For matching the engine and version of the browser browserMatch, // The deferred used on DOM ready readyList, // The ready event handler DOMContentLoaded, // Save a reference to some core methods toString = Object.prototype.toString, hasOwn = Object.prototype.hasOwnProperty, push = Array.prototype.push, slice = Array.prototype.slice, trim = String.prototype.trim, indexOf = Array.prototype.indexOf, // [[Class]] -> type pairs class2type = {}; jQuery.fn = jQuery.prototype = { constructor: jQuery, init: function( selector, context, rootjQuery ) { var match, elem, ret, doc; // Handle $(""), $(null), or $(undefined) if ( !selector ) { return this; } // Handle $(DOMElement) if ( selector.nodeType ) { this.context = this[0] = selector; this.length = 1; return this; } // The body element only exists once, optimize finding it if ( selector === "body" && !context && document.body ) { this.context = document; this[0] = document.body; this.selector = selector; this.length = 1; return this; } // Handle HTML strings if ( typeof selector === "string" ) { // Are we dealing with HTML string or an ID? if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { // Assume that strings that start and end with <> are HTML and skip the regex check match = [ null, selector, null ]; } else { match = quickExpr.exec( selector ); } // Verify a match, and that no context was specified for #id if ( match && (match[1] || !context) ) { // HANDLE: $(html) -> $(array) if ( match[1] ) { context = context instanceof jQuery ? context[0] : context; doc = ( context ? context.ownerDocument || context : document ); // If a single string is passed in and it's a single tag // just do a createElement and skip the rest ret = rsingleTag.exec( selector ); if ( ret ) { if ( jQuery.isPlainObject( context ) ) { selector = [ document.createElement( ret[1] ) ]; jQuery.fn.attr.call( selector, context, true ); } else { selector = [ doc.createElement( ret[1] ) ]; } } else { ret = jQuery.buildFragment( [ match[1] ], [ doc ] ); selector = ( ret.cacheable ? jQuery.clone(ret.fragment) : ret.fragment ).childNodes; } return jQuery.merge( this, selector ); // HANDLE: $("#id") } else { elem = document.getElementById( match[2] ); // Check parentNode to catch when Blackberry 4.6 returns // nodes that are no longer in the document #6963 if ( elem && elem.parentNode ) { // Handle the case where IE and Opera return items // by name instead of ID if ( elem.id !== match[2] ) { return rootjQuery.find( selector ); } // Otherwise, we inject the element directly into the jQuery object this.length = 1; this[0] = elem; } this.context = document; this.selector = selector; return this; } // HANDLE: $(expr, $(...)) } else if ( !context || context.jquery ) { return ( context || rootjQuery ).find( selector ); // HANDLE: $(expr, context) // (which is just equivalent to: $(context).find(expr) } else { return this.constructor( context ).find( selector ); } // HANDLE: $(function) // Shortcut for document ready } else if ( jQuery.isFunction( selector ) ) { return rootjQuery.ready( selector ); } if ( selector.selector !== undefined ) { this.selector = selector.selector; this.context = selector.context; } return jQuery.makeArray( selector, this ); }, // Start with an empty selector selector: "", // The current version of jQuery being used jquery: "1.7.1", // The default length of a jQuery object is 0 length: 0, // The number of elements contained in the matched element set size: function() { return this.length; }, toArray: function() { return slice.call( this, 0 ); }, // Get the Nth element in the matched element set OR // Get the whole matched element set as a clean array get: function( num ) { return num == null ? // Return a 'clean' array this.toArray() : // Return just the object ( num < 0 ? this[ this.length + num ] : this[ num ] ); }, // Take an array of elements and push it onto the stack // (returning the new matched element set) pushStack: function( elems, name, selector ) { // Build a new jQuery matched element set var ret = this.constructor(); if ( jQuery.isArray( elems ) ) { push.apply( ret, elems ); } else { jQuery.merge( ret, elems ); } // Add the old object onto the stack (as a reference) ret.prevObject = this; ret.context = this.context; if ( name === "find" ) { ret.selector = this.selector + ( this.selector ? " " : "" ) + selector; } else if ( name ) { ret.selector = this.selector + "." + name + "(" + selector + ")"; } // Return the newly-formed element set return ret; }, // Execute a callback for every element in the matched set. // (You can seed the arguments with an array of args, but this is // only used internally.) each: function( callback, args ) { return jQuery.each( this, callback, args ); }, ready: function( fn ) { // Attach the listeners jQuery.bindReady(); // Add the callback readyList.add( fn ); return this; }, eq: function( i ) { i = +i; return i === -1 ? this.slice( i ) : this.slice( i, i + 1 ); }, first: function() { return this.eq( 0 ); }, last: function() { return this.eq( -1 ); }, slice: function() { return this.pushStack( slice.apply( this, arguments ), "slice", slice.call(arguments).join(",") ); }, map: function( callback ) { return this.pushStack( jQuery.map(this, function( elem, i ) { return callback.call( elem, i, elem ); })); }, end: function() { return this.prevObject || this.constructor(null); }, // For internal use only. // Behaves like an Array's method, not like a jQuery method. push: push, sort: [].sort, splice: [].splice }; // Give the init function the jQuery prototype for later instantiation jQuery.fn.init.prototype = jQuery.fn; jQuery.extend = jQuery.fn.extend = function() { var options, name, src, copy, copyIsArray, clone, target = arguments[0] || {}, i = 1, length = arguments.length, deep = false; // Handle a deep copy situation if ( typeof target === "boolean" ) { deep = target; target = arguments[1] || {}; // skip the boolean and the target i = 2; } // Handle case when target is a string or something (possible in deep copy) if ( typeof target !== "object" && !jQuery.isFunction(target) ) { target = {}; } // extend jQuery itself if only one argument is passed if ( length === i ) { target = this; --i; } for ( ; i < length; i++ ) { // Only deal with non-null/undefined values if ( (options = arguments[ i ]) != null ) { // Extend the base object for ( name in options ) { src = target[ name ]; copy = options[ name ]; // Prevent never-ending loop if ( target === copy ) { continue; } // Recurse if we're merging plain objects or arrays if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { if ( copyIsArray ) { copyIsArray = false; clone = src && jQuery.isArray(src) ? src : []; } else { clone = src && jQuery.isPlainObject(src) ? src : {}; } // Never move original objects, clone them target[ name ] = jQuery.extend( deep, clone, copy ); // Don't bring in undefined values } else if ( copy !== undefined ) { target[ name ] = copy; } } } } // Return the modified object return target; }; jQuery.extend({ noConflict: function( deep ) { if ( window.$ === jQuery ) { window.$ = _$; } if ( deep && window.jQuery === jQuery ) { window.jQuery = _jQuery; } return jQuery; }, // Is the DOM ready to be used? Set to true once it occurs. isReady: false, // A counter to track how many items to wait for before // the ready event fires. See #6781 readyWait: 1, // Hold (or release) the ready event holdReady: function( hold ) { if ( hold ) { jQuery.readyWait++; } else { jQuery.ready( true ); } }, // Handle when the DOM is ready ready: function( wait ) { // Either a released hold or an DOMready/load event and not yet ready if ( (wait === true && !--jQuery.readyWait) || (wait !== true && !jQuery.isReady) ) { // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). if ( !document.body ) { return setTimeout( jQuery.ready, 1 ); } // Remember that the DOM is ready jQuery.isReady = true; // If a normal DOM Ready event fired, decrement, and wait if need be if ( wait !== true && --jQuery.readyWait > 0 ) { return; } // If there are functions bound, to execute readyList.fireWith( document, [ jQuery ] ); // Trigger any bound ready events if ( jQuery.fn.trigger ) { jQuery( document ).trigger( "ready" ).off( "ready" ); } } }, bindReady: function() { if ( readyList ) { return; } readyList = jQuery.Callbacks( "once memory" ); // Catch cases where $(document).ready() is called after the // browser event has already occurred. if ( document.readyState === "complete" ) { // Handle it asynchronously to allow scripts the opportunity to delay ready return setTimeout( jQuery.ready, 1 ); } // Mozilla, Opera and webkit nightlies currently support this event if ( document.addEventListener ) { // Use the handy event callback document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false ); // A fallback to window.onload, that will always work window.addEventListener( "load", jQuery.ready, false ); // If IE event model is used } else if ( document.attachEvent ) { // ensure firing before onload, // maybe late but safe also for iframes document.attachEvent( "onreadystatechange", DOMContentLoaded ); // A fallback to window.onload, that will always work window.attachEvent( "onload", jQuery.ready ); // If IE and not a frame // continually check to see if the document is ready var toplevel = false; try { toplevel = window.frameElement == null; } catch(e) {} if ( document.documentElement.doScroll && toplevel ) { doScrollCheck(); } } }, // See test/unit/core.js for details concerning isFunction. // Since version 1.3, DOM methods and functions like alert // aren't supported. They return false on IE (#2968). isFunction: function( obj ) { return jQuery.type(obj) === "function"; }, isArray: Array.isArray || function( obj ) { return jQuery.type(obj) === "array"; }, // A crude way of determining if an object is a window isWindow: function( obj ) { return obj && typeof obj === "object" && "setInterval" in obj; }, isNumeric: function( obj ) { return !isNaN( parseFloat(obj) ) && isFinite( obj ); }, type: function( obj ) { return obj == null ? String( obj ) : class2type[ toString.call(obj) ] || "object"; }, isPlainObject: function( obj ) { // Must be an Object. // Because of IE, we also have to check the presence of the constructor property. // Make sure that DOM nodes and window objects don't pass through, as well if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { return false; } try { // Not own constructor property must be Object if ( obj.constructor && !hasOwn.call(obj, "constructor") && !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { return false; } } catch ( e ) { // IE8,9 Will throw exceptions on certain host objects #9897 return false; } // Own properties are enumerated firstly, so to speed up, // if last one is own, then all properties are own. var key; for ( key in obj ) {} return key === undefined || hasOwn.call( obj, key ); }, isEmptyObject: function( obj ) { for ( var name in obj ) { return false; } return true; }, error: function( msg ) { throw new Error( msg ); }, parseJSON: function( data ) { if ( typeof data !== "string" || !data ) { return null; } // Make sure leading/trailing whitespace is removed (IE can't handle it) data = jQuery.trim( data ); // Attempt to parse using the native JSON parser first if ( window.JSON && window.JSON.parse ) { return window.JSON.parse( data ); } // Make sure the incoming data is actual JSON // Logic borrowed from http://json.org/json2.js if ( rvalidchars.test( data.replace( rvalidescape, "@" ) .replace( rvalidtokens, "]" ) .replace( rvalidbraces, "")) ) { return ( new Function( "return " + data ) )(); } jQuery.error( "Invalid JSON: " + data ); }, // Cross-browser xml parsing parseXML: function( data ) { var xml, tmp; try { if ( window.DOMParser ) { // Standard tmp = new DOMParser(); xml = tmp.parseFromString( data , "text/xml" ); } else { // IE xml = new ActiveXObject( "Microsoft.XMLDOM" ); xml.async = "false"; xml.loadXML( data ); } } catch( e ) { xml = undefined; } if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { jQuery.error( "Invalid XML: " + data ); } return xml; }, noop: function() {}, // Evaluates a script in a global context // Workarounds based on findings by Jim Driscoll // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context globalEval: function( data ) { if ( data && rnotwhite.test( data ) ) { // We use execScript on Internet Explorer // We use an anonymous function so that context is window // rather than jQuery in Firefox ( window.execScript || function( data ) { window[ "eval" ].call( window, data ); } )( data ); } }, // Convert dashed to camelCase; used by the css and data modules // Microsoft forgot to hump their vendor prefix (#9572) camelCase: function( string ) { return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); }, nodeName: function( elem, name ) { return elem.nodeName && elem.nodeName.toUpperCase() === name.toUpperCase(); }, // args is for internal usage only each: function( object, callback, args ) { var name, i = 0, length = object.length, isObj = length === undefined || jQuery.isFunction( object ); if ( args ) { if ( isObj ) { for ( name in object ) { if ( callback.apply( object[ name ], args ) === false ) { break; } } } else { for ( ; i < length; ) { if ( callback.apply( object[ i++ ], args ) === false ) { break; } } } // A special, fast, case for the most common use of each } else { if ( isObj ) { for ( name in object ) { if ( callback.call( object[ name ], name, object[ name ] ) === false ) { break; } } } else { for ( ; i < length; ) { if ( callback.call( object[ i ], i, object[ i++ ] ) === false ) { break; } } } } return object; }, // Use native String.trim function wherever possible trim: trim ? function( text ) { return text == null ? "" : trim.call( text ); } : // Otherwise use our own trimming functionality function( text ) { return text == null ? "" : text.toString().replace( trimLeft, "" ).replace( trimRight, "" ); }, // results is for internal usage only makeArray: function( array, results ) { var ret = results || []; if ( array != null ) { // The window, strings (and functions) also have 'length' // Tweaked logic slightly to handle Blackberry 4.7 RegExp issues #6930 var type = jQuery.type( array ); if ( array.length == null || type === "string" || type === "function" || type === "regexp" || jQuery.isWindow( array ) ) { push.call( ret, array ); } else { jQuery.merge( ret, array ); } } return ret; }, inArray: function( elem, array, i ) { var len; if ( array ) { if ( indexOf ) { return indexOf.call( array, elem, i ); } len = array.length; i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; for ( ; i < len; i++ ) { // Skip accessing in sparse arrays if ( i in array && array[ i ] === elem ) { return i; } } } return -1; }, merge: function( first, second ) { var i = first.length, j = 0; if ( typeof second.length === "number" ) { for ( var l = second.length; j < l; j++ ) { first[ i++ ] = second[ j ]; } } else { while ( second[j] !== undefined ) { first[ i++ ] = second[ j++ ]; } } first.length = i; return first; }, grep: function( elems, callback, inv ) { var ret = [], retVal; inv = !!inv; // Go through the array, only saving the items // that pass the validator function for ( var i = 0, length = elems.length; i < length; i++ ) { retVal = !!callback( elems[ i ], i ); if ( inv !== retVal ) { ret.push( elems[ i ] ); } } return ret; }, // arg is for internal usage only map: function( elems, callback, arg ) { var value, key, ret = [], i = 0, length = elems.length, // jquery objects are treated as arrays isArray = elems instanceof jQuery || length !== undefined && typeof length === "number" && ( ( length > 0 && elems[ 0 ] && elems[ length -1 ] ) || length === 0 || jQuery.isArray( elems ) ) ; // Go through the array, translating each of the items to their if ( isArray ) { for ( ; i < length; i++ ) { value = callback( elems[ i ], i, arg ); if ( value != null ) { ret[ ret.length ] = value; } } // Go through every key on the object, } else { for ( key in elems ) { value = callback( elems[ key ], key, arg ); if ( value != null ) { ret[ ret.length ] = value; } } } // Flatten any nested arrays return ret.concat.apply( [], ret ); }, // A global GUID counter for objects guid: 1, // Bind a function to a context, optionally partially applying any // arguments. proxy: function( fn, context ) { if ( typeof context === "string" ) { var tmp = fn[ context ]; context = fn; fn = tmp; } // Quick check to determine if target is callable, in the spec // this throws a TypeError, but we will just return undefined. if ( !jQuery.isFunction( fn ) ) { return undefined; } // Simulated bind var args = slice.call( arguments, 2 ), proxy = function() { return fn.apply( context, args.concat( slice.call( arguments ) ) ); }; // Set the guid of unique handler to the same of original handler, so it can be removed proxy.guid = fn.guid = fn.guid || proxy.guid || jQuery.guid++; return proxy; }, // Mutifunctional method to get and set values to a collection // The value/s can optionally be executed if it's a function access: function( elems, key, value, exec, fn, pass ) { var length = elems.length; // Setting many attributes if ( typeof key === "object" ) { for ( var k in key ) { jQuery.access( elems, k, key[k], exec, fn, value ); } return elems; } // Setting one attribute if ( value !== undefined ) { // Optionally, function values get executed if exec is true exec = !pass && exec && jQuery.isFunction(value); for ( var i = 0; i < length; i++ ) { fn( elems[i], key, exec ? value.call( elems[i], i, fn( elems[i], key ) ) : value, pass ); } return elems; } // Getting an attribute return length ? fn( elems[0], key ) : undefined; }, now: function() { return ( new Date() ).getTime(); }, // Use of jQuery.browser is frowned upon. // More details: http://docs.jquery.com/Utilities/jQuery.browser uaMatch: function( ua ) { ua = ua.toLowerCase(); var match = rwebkit.exec( ua ) || ropera.exec( ua ) || rmsie.exec( ua ) || ua.indexOf("compatible") < 0 && rmozilla.exec( ua ) || []; return { browser: match[1] || "", version: match[2] || "0" }; }, sub: function() { function jQuerySub( selector, context ) { return new jQuerySub.fn.init( selector, context ); } jQuery.extend( true, jQuerySub, this ); jQuerySub.superclass = this; jQuerySub.fn = jQuerySub.prototype = this(); jQuerySub.fn.constructor = jQuerySub; jQuerySub.sub = this.sub; jQuerySub.fn.init = function init( selector, context ) { if ( context && context instanceof jQuery && !(context instanceof jQuerySub) ) { context = jQuerySub( context ); } return jQuery.fn.init.call( this, selector, context, rootjQuerySub ); }; jQuerySub.fn.init.prototype = jQuerySub.fn; var rootjQuerySub = jQuerySub(document); return jQuerySub; }, browser: {} }); // Populate the class2type map jQuery.each("Boolean Number String Function Array Date RegExp Object".split(" "), function(i, name) { class2type[ "[object " + name + "]" ] = name.toLowerCase(); }); browserMatch = jQuery.uaMatch( userAgent ); if ( browserMatch.browser ) { jQuery.browser[ browserMatch.browser ] = true; jQuery.browser.version = browserMatch.version; } // Deprecated, use jQuery.browser.webkit instead if ( jQuery.browser.webkit ) { jQuery.browser.safari = true; } // IE doesn't match non-breaking spaces with \s if ( rnotwhite.test( "\xA0" ) ) { trimLeft = /^[\s\xA0]+/; trimRight = /[\s\xA0]+$/; } // All jQuery objects should point back to these rootjQuery = jQuery(document); // Cleanup functions for the document ready method if ( document.addEventListener ) { DOMContentLoaded = function() { document.removeEventListener( "DOMContentLoaded", DOMContentLoaded, false ); jQuery.ready(); }; } else if ( document.attachEvent ) { DOMContentLoaded = function() { // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). if ( document.readyState === "complete" ) { document.detachEvent( "onreadystatechange", DOMContentLoaded ); jQuery.ready(); } }; } // The DOM ready check for Internet Explorer function doScrollCheck() { if ( jQuery.isReady ) { return; } try { // If IE is used, use the trick by Diego Perini // http://javascript.nwbox.com/IEContentLoaded/ document.documentElement.doScroll("left"); } catch(e) { setTimeout( doScrollCheck, 1 ); return; } // and execute any waiting functions jQuery.ready(); } return jQuery; })(); // String to Object flags format cache var flagsCache = {}; // Convert String-formatted flags into Object-formatted ones and store in cache function createFlags( flags ) { var object = flagsCache[ flags ] = {}, i, length; flags = flags.split( /\s+/ ); for ( i = 0, length = flags.length; i < length; i++ ) { object[ flags[i] ] = true; } return object; } /* * Create a callback list using the following parameters: * * flags: an optional list of space-separated flags that will change how * the callback list behaves * * By default a callback list will act like an event callback list and can be * "fired" multiple times. * * Possible flags: * * once: will ensure the callback list can only be fired once (like a Deferred) * * memory: will keep track of previous values and will call any callback added * after the list has been fired right away with the latest "memorized" * values (like a Deferred) * * unique: will ensure a callback can only be added once (no duplicate in the list) * * stopOnFalse: interrupt callings when a callback returns false * */ jQuery.Callbacks = function( flags ) { // Convert flags from String-formatted to Object-formatted // (we check in cache first) flags = flags ? ( flagsCache[ flags ] || createFlags( flags ) ) : {}; var // Actual callback list list = [], // Stack of fire calls for repeatable lists stack = [], // Last fire value (for non-forgettable lists) memory, // Flag to know if list is currently firing firing, // First callback to fire (used internally by add and fireWith) firingStart, // End of the loop when firing firingLength, // Index of currently firing callback (modified by remove if needed) firingIndex, // Add one or several callbacks to the list add = function( args ) { var i, length, elem, type, actual; for ( i = 0, length = args.length; i < length; i++ ) { elem = args[ i ]; type = jQuery.type( elem ); if ( type === "array" ) { // Inspect recursively add( elem ); } else if ( type === "function" ) { // Add if not in unique mode and callback is not in if ( !flags.unique || !self.has( elem ) ) { list.push( elem ); } } } }, // Fire callbacks fire = function( context, args ) { args = args || []; memory = !flags.memory || [ context, args ]; firing = true; firingIndex = firingStart || 0; firingStart = 0; firingLength = list.length; for ( ; list && firingIndex < firingLength; firingIndex++ ) { if ( list[ firingIndex ].apply( context, args ) === false && flags.stopOnFalse ) { memory = true; // Mark as halted break; } } firing = false; if ( list ) { if ( !flags.once ) { if ( stack && stack.length ) { memory = stack.shift(); self.fireWith( memory[ 0 ], memory[ 1 ] ); } } else if ( memory === true ) { self.disable(); } else { list = []; } } }, // Actual Callbacks object self = { // Add a callback or a collection of callbacks to the list add: function() { if ( list ) { var length = list.length; add( arguments ); // Do we need to add the callbacks to the // current firing batch? if ( firing ) { firingLength = list.length; // With memory, if we're not firing then // we should call right away, unless previous // firing was halted (stopOnFalse) } else if ( memory && memory !== true ) { firingStart = length; fire( memory[ 0 ], memory[ 1 ] ); } } return this; }, // Remove a callback from the list remove: function() { if ( list ) { var args = arguments, argIndex = 0, argLength = args.length; for ( ; argIndex < argLength ; argIndex++ ) { for ( var i = 0; i < list.length; i++ ) { if ( args[ argIndex ] === list[ i ] ) { // Handle firingIndex and firingLength if ( firing ) { if ( i <= firingLength ) { firingLength--; if ( i <= firingIndex ) { firingIndex--; } } } // Remove the element list.splice( i--, 1 ); // If we have some unicity property then // we only need to do this once if ( flags.unique ) { break; } } } } } return this; }, // Control if a given callback is in the list has: function( fn ) { if ( list ) { var i = 0, length = list.length; for ( ; i < length; i++ ) { if ( fn === list[ i ] ) { return true; } } } return false; }, // Remove all callbacks from the list empty: function() { list = []; return this; }, // Have the list do nothing anymore disable: function() { list = stack = memory = undefined; return this; }, // Is it disabled? disabled: function() { return !list; }, // Lock the list in its current state lock: function() { stack = undefined; if ( !memory || memory === true ) { self.disable(); } return this; }, // Is it locked? locked: function() { return !stack; }, // Call all callbacks with the given context and arguments fireWith: function( context, args ) { if ( stack ) { if ( firing ) { if ( !flags.once ) { stack.push( [ context, args ] ); } } else if ( !( flags.once && memory ) ) { fire( context, args ); } } return this; }, // Call all the callbacks with the given arguments fire: function() { self.fireWith( this, arguments ); return this; }, // To know if the callbacks have already been called at least once fired: function() { return !!memory; } }; return self; }; var // Static reference to slice sliceDeferred = [].slice; jQuery.extend({ Deferred: function( func ) { var doneList = jQuery.Callbacks( "once memory" ), failList = jQuery.Callbacks( "once memory" ), progressList = jQuery.Callbacks( "memory" ), state = "pending", lists = { resolve: doneList, reject: failList, notify: progressList }, promise = { done: doneList.add, fail: failList.add, progress: progressList.add, state: function() { return state; }, // Deprecated isResolved: doneList.fired, isRejected: failList.fired, then: function( doneCallbacks, failCallbacks, progressCallbacks ) { deferred.done( doneCallbacks ).fail( failCallbacks ).progress( progressCallbacks ); return this; }, always: function() { deferred.done.apply( deferred, arguments ).fail.apply( deferred, arguments ); return this; }, pipe: function( fnDone, fnFail, fnProgress ) { return jQuery.Deferred(function( newDefer ) { jQuery.each( { done: [ fnDone, "resolve" ], fail: [ fnFail, "reject" ], progress: [ fnProgress, "notify" ] }, function( handler, data ) { var fn = data[ 0 ], action = data[ 1 ], returned; if ( jQuery.isFunction( fn ) ) { deferred[ handler ](function() { returned = fn.apply( this, arguments ); if ( returned && jQuery.isFunction( returned.promise ) ) { returned.promise().then( newDefer.resolve, newDefer.reject, newDefer.notify ); } else { newDefer[ action + "With" ]( this === deferred ? newDefer : this, [ returned ] ); } }); } else { deferred[ handler ]( newDefer[ action ] ); } }); }).promise(); }, // Get a promise for this deferred // If obj is provided, the promise aspect is added to the object promise: function( obj ) { if ( obj == null ) { obj = promise; } else { for ( var key in promise ) { obj[ key ] = promise[ key ]; } } return obj; } }, deferred = promise.promise({}), key; for ( key in lists ) { deferred[ key ] = lists[ key ].fire; deferred[ key + "With" ] = lists[ key ].fireWith; } // Handle state deferred.done( function() { state = "resolved"; }, failList.disable, progressList.lock ).fail( function() { state = "rejected"; }, doneList.disable, progressList.lock ); // Call given func if any if ( func ) { func.call( deferred, deferred ); } // All done! return deferred; }, // Deferred helper when: function( firstParam ) { var args = sliceDeferred.call( arguments, 0 ), i = 0, length = args.length, pValues = new Array( length ), count = length, pCount = length, deferred = length <= 1 && firstParam && jQuery.isFunction( firstParam.promise ) ? firstParam : jQuery.Deferred(), promise = deferred.promise(); function resolveFunc( i ) { return function( value ) { args[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value; if ( !( --count ) ) { deferred.resolveWith( deferred, args ); } }; } function progressFunc( i ) { return function( value ) { pValues[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value; deferred.notifyWith( promise, pValues ); }; } if ( length > 1 ) { for ( ; i < length; i++ ) { if ( args[ i ] && args[ i ].promise && jQuery.isFunction( args[ i ].promise ) ) { args[ i ].promise().then( resolveFunc(i), deferred.reject, progressFunc(i) ); } else { --count; } } if ( !count ) { deferred.resolveWith( deferred, args ); } } else if ( deferred !== firstParam ) { deferred.resolveWith( deferred, length ? [ firstParam ] : [] ); } return promise; } }); jQuery.support = (function() { var support, all, a, select, opt, input, marginDiv, fragment, tds, events, eventName, i, isSupported, div = document.createElement( "div" ), documentElement = document.documentElement; // Preliminary tests div.setAttribute("className", "t"); div.innerHTML = " <link/><table></table><a href='/a' style='top:1px;float:left;opacity:.55;'>a</a><input type='checkbox'/>"; all = div.getElementsByTagName( "*" ); a = div.getElementsByTagName( "a" )[ 0 ]; // Can't get basic test support if ( !all || !all.length || !a ) { return {}; } // First batch of supports tests select = document.createElement( "select" ); opt = select.appendChild( document.createElement("option") ); input = div.getElementsByTagName( "input" )[ 0 ]; support = { // IE strips leading whitespace when .innerHTML is used leadingWhitespace: ( div.firstChild.nodeType === 3 ), // Make sure that tbody elements aren't automatically inserted // IE will insert them into empty tables tbody: !div.getElementsByTagName("tbody").length, // Make sure that link elements get serialized correctly by innerHTML // This requires a wrapper element in IE htmlSerialize: !!div.getElementsByTagName("link").length, // Get the style information from getAttribute // (IE uses .cssText instead) style: /top/.test( a.getAttribute("style") ), // Make sure that URLs aren't manipulated // (IE normalizes it by default) hrefNormalized: ( a.getAttribute("href") === "/a" ), // Make sure that element opacity exists // (IE uses filter instead) // Use a regex to work around a WebKit issue. See #5145 opacity: /^0.55/.test( a.style.opacity ), // Verify style float existence // (IE uses styleFloat instead of cssFloat) cssFloat: !!a.style.cssFloat, // Make sure that if no value is specified for a checkbox // that it defaults to "on". // (WebKit defaults to "" instead) checkOn: ( input.value === "on" ), // Make sure that a selected-by-default option has a working selected property. // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) optSelected: opt.selected, // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) getSetAttribute: div.className !== "t", // Tests for enctype support on a form(#6743) enctype: !!document.createElement("form").enctype, // Makes sure cloning an html5 element does not cause problems // Where outerHTML is undefined, this still works html5Clone: document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav></:nav>", // Will be defined later submitBubbles: true, changeBubbles: true, focusinBubbles: false, deleteExpando: true, noCloneEvent: true, inlineBlockNeedsLayout: false, shrinkWrapBlocks: false, reliableMarginRight: true }; // Make sure checked status is properly cloned input.checked = true; support.noCloneChecked = input.cloneNode( true ).checked; // Make sure that the options inside disabled selects aren't marked as disabled // (WebKit marks them as disabled) select.disabled = true; support.optDisabled = !opt.disabled; // Test to see if it's possible to delete an expando from an element // Fails in Internet Explorer try { delete div.test; } catch( e ) { support.deleteExpando = false; } if ( !div.addEventListener && div.attachEvent && div.fireEvent ) { div.attachEvent( "onclick", function() { // Cloning a node shouldn't copy over any // bound event handlers (IE does this) support.noCloneEvent = false; }); div.cloneNode( true ).fireEvent( "onclick" ); } // Check if a radio maintains its value // after being appended to the DOM input = document.createElement("input"); input.value = "t"; input.setAttribute("type", "radio"); support.radioValue = input.value === "t"; input.setAttribute("checked", "checked"); div.appendChild( input ); fragment = document.createDocumentFragment(); fragment.appendChild( div.lastChild ); // WebKit doesn't clone checked state correctly in fragments support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked; // Check if a disconnected checkbox will retain its checked // value of true after appended to the DOM (IE6/7) support.appendChecked = input.checked; fragment.removeChild( input ); fragment.appendChild( div ); div.innerHTML = ""; // Check if div with explicit width and no margin-right incorrectly // gets computed margin-right based on width of container. For more // info see bug #3333 // Fails in WebKit before Feb 2011 nightlies // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right if ( window.getComputedStyle ) { marginDiv = document.createElement( "div" ); marginDiv.style.width = "0"; marginDiv.style.marginRight = "0"; div.style.width = "2px"; div.appendChild( marginDiv ); support.reliableMarginRight = ( parseInt( ( window.getComputedStyle( marginDiv, null ) || { marginRight: 0 } ).marginRight, 10 ) || 0 ) === 0; } // Technique from Juriy Zaytsev // http://perfectionkills.com/detecting-event-support-without-browser-sniffing/ // We only care about the case where non-standard event systems // are used, namely in IE. Short-circuiting here helps us to // avoid an eval call (in setAttribute) which can cause CSP // to go haywire. See: https://developer.mozilla.org/en/Security/CSP if ( div.attachEvent ) { for( i in { submit: 1, change: 1, focusin: 1 }) { eventName = "on" + i; isSupported = ( eventName in div ); if ( !isSupported ) { div.setAttribute( eventName, "return;" ); isSupported = ( typeof div[ eventName ] === "function" ); } support[ i + "Bubbles" ] = isSupported; } } fragment.removeChild( div ); // Null elements to avoid leaks in IE fragment = select = opt = marginDiv = div = input = null; // Run tests that need a body at doc ready jQuery(function() { var container, outer, inner, table, td, offsetSupport, conMarginTop, ptlm, vb, style, html, body = document.getElementsByTagName("body")[0]; if ( !body ) { // Return for frameset docs that don't have a body return; } conMarginTop = 1; ptlm = "position:absolute;top:0;left:0;width:1px;height:1px;margin:0;"; vb = "visibility:hidden;border:0;"; style = "style='" + ptlm + "border:5px solid #000;padding:0;'"; html = "<div " + style + "><div></div></div>" + "<table " + style + " cellpadding='0' cellspacing='0'>" + "<tr><td></td></tr></table>"; container = document.createElement("div"); container.style.cssText = vb + "width:0;height:0;position:static;top:0;margin-top:" + conMarginTop + "px"; body.insertBefore( container, body.firstChild ); // Construct the test element div = document.createElement("div"); container.appendChild( div ); // Check if table cells still have offsetWidth/Height when they are set // to display:none and there are still other visible table cells in a // table row; if so, offsetWidth/Height are not reliable for use when // determining if an element has been hidden directly using // display:none (it is still safe to use offsets if a parent element is // hidden; don safety goggles and see bug #4512 for more information). // (only IE 8 fails this test) div.innerHTML = "<table><tr><td style='padding:0;border:0;display:none'></td><td>t</td></tr></table>"; tds = div.getElementsByTagName( "td" ); isSupported = ( tds[ 0 ].offsetHeight === 0 ); tds[ 0 ].style.display = ""; tds[ 1 ].style.display = "none"; // Check if empty table cells still have offsetWidth/Height // (IE <= 8 fail this test) support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 ); // Figure out if the W3C box model works as expected div.innerHTML = ""; div.style.width = div.style.paddingLeft = "1px"; jQuery.boxModel = support.boxModel = div.offsetWidth === 2; if ( typeof div.style.zoom !== "undefined" ) { // Check if natively block-level elements act like inline-block // elements when setting their display to 'inline' and giving // them layout // (IE < 8 does this) div.style.display = "inline"; div.style.zoom = 1; support.inlineBlockNeedsLayout = ( div.offsetWidth === 2 ); // Check if elements with layout shrink-wrap their children // (IE 6 does this) div.style.display = ""; div.innerHTML = "<div style='width:4px;'></div>"; support.shrinkWrapBlocks = ( div.offsetWidth !== 2 ); } div.style.cssText = ptlm + vb; div.innerHTML = html; outer = div.firstChild; inner = outer.firstChild; td = outer.nextSibling.firstChild.firstChild; offsetSupport = { doesNotAddBorder: ( inner.offsetTop !== 5 ), doesAddBorderForTableAndCells: ( td.offsetTop === 5 ) }; inner.style.position = "fixed"; inner.style.top = "20px"; // safari subtracts parent border width here which is 5px offsetSupport.fixedPosition = ( inner.offsetTop === 20 || inner.offsetTop === 15 ); inner.style.position = inner.style.top = ""; outer.style.overflow = "hidden"; outer.style.position = "relative"; offsetSupport.subtractsBorderForOverflowNotVisible = ( inner.offsetTop === -5 ); offsetSupport.doesNotIncludeMarginInBodyOffset = ( body.offsetTop !== conMarginTop ); body.removeChild( container ); div = container = null; jQuery.extend( support, offsetSupport ); }); return support; })(); var rbrace = /^(?:\{.*\}|\[.*\])$/, rmultiDash = /([A-Z])/g; jQuery.extend({ cache: {}, // Please use with caution uuid: 0, // Unique for each copy of jQuery on the page // Non-digits removed to match rinlinejQuery expando: "jQuery" + ( jQuery.fn.jquery + Math.random() ).replace( /\D/g, "" ), // The following elements throw uncatchable exceptions if you // attempt to add expando properties to them. noData: { "embed": true, // Ban all objects except for Flash (which handle expandos) "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000", "applet": true }, hasData: function( elem ) { elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; return !!elem && !isEmptyDataObject( elem ); }, data: function( elem, name, data, pvt /* Internal Use Only */ ) { if ( !jQuery.acceptData( elem ) ) { return; } var privateCache, thisCache, ret, internalKey = jQuery.expando, getByName = typeof name === "string", // We have to handle DOM nodes and JS objects differently because IE6-7 // can't GC object references properly across the DOM-JS boundary isNode = elem.nodeType, // Only DOM nodes need the global jQuery cache; JS object data is // attached directly to the object so GC can occur automatically cache = isNode ? jQuery.cache : elem, // Only defining an ID for JS objects if its cache already exists allows // the code to shortcut on the same path as a DOM node with no cache id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey, isEvents = name === "events"; // Avoid doing any more work than we need to when trying to get data on an // object that has no data at all if ( (!id || !cache[id] || (!isEvents && !pvt && !cache[id].data)) && getByName && data === undefined ) { return; } if ( !id ) { // Only DOM nodes need a new unique ID for each element since their data // ends up in the global cache if ( isNode ) { elem[ internalKey ] = id = ++jQuery.uuid; } else { id = internalKey; } } if ( !cache[ id ] ) { cache[ id ] = {}; // Avoids exposing jQuery metadata on plain JS objects when the object // is serialized using JSON.stringify if ( !isNode ) { cache[ id ].toJSON = jQuery.noop; } } // An object can be passed to jQuery.data instead of a key/value pair; this gets // shallow copied over onto the existing cache if ( typeof name === "object" || typeof name === "function" ) { if ( pvt ) { cache[ id ] = jQuery.extend( cache[ id ], name ); } else { cache[ id ].data = jQuery.extend( cache[ id ].data, name ); } } privateCache = thisCache = cache[ id ]; // jQuery data() is stored in a separate object inside the object's internal data // cache in order to avoid key collisions between internal data and user-defined // data. if ( !pvt ) { if ( !thisCache.data ) { thisCache.data = {}; } thisCache = thisCache.data; } if ( data !== undefined ) { thisCache[ jQuery.camelCase( name ) ] = data; } // Users should not attempt to inspect the internal events object using jQuery.data, // it is undocumented and subject to change. But does anyone listen? No. if ( isEvents && !thisCache[ name ] ) { return privateCache.events; } // Check for both converted-to-camel and non-converted data property names // If a data property was specified if ( getByName ) { // First Try to find as-is property data ret = thisCache[ name ]; // Test for null|undefined property data if ( ret == null ) { // Try to find the camelCased property ret = thisCache[ jQuery.camelCase( name ) ]; } } else { ret = thisCache; } return ret; }, removeData: function( elem, name, pvt /* Internal Use Only */ ) { if ( !jQuery.acceptData( elem ) ) { return; } var thisCache, i, l, // Reference to internal data cache key internalKey = jQuery.expando, isNode = elem.nodeType, // See jQuery.data for more information cache = isNode ? jQuery.cache : elem, // See jQuery.data for more information id = isNode ? elem[ internalKey ] : internalKey; // If there is already no cache entry for this object, there is no // purpose in continuing if ( !cache[ id ] ) { return; } if ( name ) { thisCache = pvt ? cache[ id ] : cache[ id ].data; if ( thisCache ) { // Support array or space separated string names for data keys if ( !jQuery.isArray( name ) ) { // try the string as a key before any manipulation if ( name in thisCache ) { name = [ name ]; } else { // split the camel cased version by spaces unless a key with the spaces exists name = jQuery.camelCase( name ); if ( name in thisCache ) { name = [ name ]; } else { name = name.split( " " ); } } } for ( i = 0, l = name.length; i < l; i++ ) { delete thisCache[ name[i] ]; } // If there is no data left in the cache, we want to continue // and let the cache object itself get destroyed if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) { return; } } } // See jQuery.data for more information if ( !pvt ) { delete cache[ id ].data; // Don't destroy the parent cache unless the internal data object // had been the only thing left in it if ( !isEmptyDataObject(cache[ id ]) ) { return; } } // Browsers that fail expando deletion also refuse to delete expandos on // the window, but it will allow it on all other JS objects; other browsers // don't care // Ensure that `cache` is not a window object #10080 if ( jQuery.support.deleteExpando || !cache.setInterval ) { delete cache[ id ]; } else { cache[ id ] = null; } // We destroyed the cache and need to eliminate the expando on the node to avoid // false lookups in the cache for entries that no longer exist if ( isNode ) { // IE does not allow us to delete expando properties from nodes, // nor does it have a removeAttribute function on Document nodes; // we must handle all of these cases if ( jQuery.support.deleteExpando ) { delete elem[ internalKey ]; } else if ( elem.removeAttribute ) { elem.removeAttribute( internalKey ); } else { elem[ internalKey ] = null; } } }, // For internal use only. _data: function( elem, name, data ) { return jQuery.data( elem, name, data, true ); }, // A method for determining if a DOM node can handle the data expando acceptData: function( elem ) { if ( elem.nodeName ) { var match = jQuery.noData[ elem.nodeName.toLowerCase() ]; if ( match ) { return !(match === true || elem.getAttribute("classid") !== match); } } return true; } }); jQuery.fn.extend({ data: function( key, value ) { var parts, attr, name, data = null; if ( typeof key === "undefined" ) { if ( this.length ) { data = jQuery.data( this[0] ); if ( this[0].nodeType === 1 && !jQuery._data( this[0], "parsedAttrs" ) ) { attr = this[0].attributes; for ( var i = 0, l = attr.length; i < l; i++ ) { name = attr[i].name; if ( name.indexOf( "data-" ) === 0 ) { name = jQuery.camelCase( name.substring(5) ); dataAttr( this[0], name, data[ name ] ); } } jQuery._data( this[0], "parsedAttrs", true ); } } return data; } else if ( typeof key === "object" ) { return this.each(function() { jQuery.data( this, key ); }); } parts = key.split("."); parts[1] = parts[1] ? "." + parts[1] : ""; if ( value === undefined ) { data = this.triggerHandler("getData" + parts[1] + "!", [parts[0]]); // Try to fetch any internally stored data first if ( data === undefined && this.length ) { data = jQuery.data( this[0], key ); data = dataAttr( this[0], key, data ); } return data === undefined && parts[1] ? this.data( parts[0] ) : data; } else { return this.each(function() { var self = jQuery( this ), args = [ parts[0], value ]; self.triggerHandler( "setData" + parts[1] + "!", args ); jQuery.data( this, key, value ); self.triggerHandler( "changeData" + parts[1] + "!", args ); }); } }, removeData: function( key ) { return this.each(function() { jQuery.removeData( this, key ); }); } }); function dataAttr( elem, key, data ) { // If nothing was found internally, try to fetch any // data from the HTML5 data-* attribute if ( data === undefined && elem.nodeType === 1 ) { var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); data = elem.getAttribute( name ); if ( typeof data === "string" ) { try { data = data === "true" ? true : data === "false" ? false : data === "null" ? null : jQuery.isNumeric( data ) ? parseFloat( data ) : rbrace.test( data ) ? jQuery.parseJSON( data ) : data; } catch( e ) {} // Make sure we set the data so it isn't changed later jQuery.data( elem, key, data ); } else { data = undefined; } } return data; } // checks a cache object for emptiness function isEmptyDataObject( obj ) { for ( var name in obj ) { // if the public data object is empty, the private is still empty if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { continue; } if ( name !== "toJSON" ) { return false; } } return true; } function handleQueueMarkDefer( elem, type, src ) { var deferDataKey = type + "defer", queueDataKey = type + "queue", markDataKey = type + "mark", defer = jQuery._data( elem, deferDataKey ); if ( defer && ( src === "queue" || !jQuery._data(elem, queueDataKey) ) && ( src === "mark" || !jQuery._data(elem, markDataKey) ) ) { // Give room for hard-coded callbacks to fire first // and eventually mark/queue something else on the element setTimeout( function() { if ( !jQuery._data( elem, queueDataKey ) && !jQuery._data( elem, markDataKey ) ) { jQuery.removeData( elem, deferDataKey, true ); defer.fire(); } }, 0 ); } } jQuery.extend({ _mark: function( elem, type ) { if ( elem ) { type = ( type || "fx" ) + "mark"; jQuery._data( elem, type, (jQuery._data( elem, type ) || 0) + 1 ); } }, _unmark: function( force, elem, type ) { if ( force !== true ) { type = elem; elem = force; force = false; } if ( elem ) { type = type || "fx"; var key = type + "mark", count = force ? 0 : ( (jQuery._data( elem, key ) || 1) - 1 ); if ( count ) { jQuery._data( elem, key, count ); } else { jQuery.removeData( elem, key, true ); handleQueueMarkDefer( elem, type, "mark" ); } } }, queue: function( elem, type, data ) { var q; if ( elem ) { type = ( type || "fx" ) + "queue"; q = jQuery._data( elem, type ); // Speed up dequeue by getting out quickly if this is just a lookup if ( data ) { if ( !q || jQuery.isArray(data) ) { q = jQuery._data( elem, type, jQuery.makeArray(data) ); } else { q.push( data ); } } return q || []; } }, dequeue: function( elem, type ) { type = type || "fx"; var queue = jQuery.queue( elem, type ), fn = queue.shift(), hooks = {}; // If the fx queue is dequeued, always remove the progress sentinel if ( fn === "inprogress" ) { fn = queue.shift(); } if ( fn ) { // Add a progress sentinel to prevent the fx queue from being // automatically dequeued if ( type === "fx" ) { queue.unshift( "inprogress" ); } jQuery._data( elem, type + ".run", hooks ); fn.call( elem, function() { jQuery.dequeue( elem, type ); }, hooks ); } if ( !queue.length ) { jQuery.removeData( elem, type + "queue " + type + ".run", true ); handleQueueMarkDefer( elem, type, "queue" ); } } }); jQuery.fn.extend({ queue: function( type, data ) { if ( typeof type !== "string" ) { data = type; type = "fx"; } if ( data === undefined ) { return jQuery.queue( this[0], type ); } return this.each(function() { var queue = jQuery.queue( this, type, data ); if ( type === "fx" && queue[0] !== "inprogress" ) { jQuery.dequeue( this, type ); } }); }, dequeue: function( type ) { return this.each(function() { jQuery.dequeue( this, type ); }); }, // Based off of the plugin by Clint Helfers, with permission. // http://blindsignals.com/index.php/2009/07/jquery-delay/ delay: function( time, type ) { time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; type = type || "fx"; return this.queue( type, function( next, hooks ) { var timeout = setTimeout( next, time ); hooks.stop = function() { clearTimeout( timeout ); }; }); }, clearQueue: function( type ) { return this.queue( type || "fx", [] ); }, // Get a promise resolved when queues of a certain type // are emptied (fx is the type by default) promise: function( type, object ) { if ( typeof type !== "string" ) { object = type; type = undefined; } type = type || "fx"; var defer = jQuery.Deferred(), elements = this, i = elements.length, count = 1, deferDataKey = type + "defer", queueDataKey = type + "queue", markDataKey = type + "mark", tmp; function resolve() { if ( !( --count ) ) { defer.resolveWith( elements, [ elements ] ); } } while( i-- ) { if (( tmp = jQuery.data( elements[ i ], deferDataKey, undefined, true ) || ( jQuery.data( elements[ i ], queueDataKey, undefined, true ) || jQuery.data( elements[ i ], markDataKey, undefined, true ) ) && jQuery.data( elements[ i ], deferDataKey, jQuery.Callbacks( "once memory" ), true ) )) { count++; tmp.add( resolve ); } } resolve(); return defer.promise(); } }); var rclass = /[\n\t\r]/g, rspace = /\s+/, rreturn = /\r/g, rtype = /^(?:button|input)$/i, rfocusable = /^(?:button|input|object|select|textarea)$/i, rclickable = /^a(?:rea)?$/i, rboolean = /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i, getSetAttribute = jQuery.support.getSetAttribute, nodeHook, boolHook, fixSpecified; jQuery.fn.extend({ attr: function( name, value ) { return jQuery.access( this, name, value, true, jQuery.attr ); }, removeAttr: function( name ) { return this.each(function() { jQuery.removeAttr( this, name ); }); }, prop: function( name, value ) { return jQuery.access( this, name, value, true, jQuery.prop ); }, removeProp: function( name ) { name = jQuery.propFix[ name ] || name; return this.each(function() { // try/catch handles cases where IE balks (such as removing a property on window) try { this[ name ] = undefined; delete this[ name ]; } catch( e ) {} }); }, addClass: function( value ) { var classNames, i, l, elem, setClass, c, cl; if ( jQuery.isFunction( value ) ) { return this.each(function( j ) { jQuery( this ).addClass( value.call(this, j, this.className) ); }); } if ( value && typeof value === "string" ) { classNames = value.split( rspace ); for ( i = 0, l = this.length; i < l; i++ ) { elem = this[ i ]; if ( elem.nodeType === 1 ) { if ( !elem.className && classNames.length === 1 ) { elem.className = value; } else { setClass = " " + elem.className + " "; for ( c = 0, cl = classNames.length; c < cl; c++ ) { if ( !~setClass.indexOf( " " + classNames[ c ] + " " ) ) { setClass += classNames[ c ] + " "; } } elem.className = jQuery.trim( setClass ); } } } } return this; }, removeClass: function( value ) { var classNames, i, l, elem, className, c, cl; if ( jQuery.isFunction( value ) ) { return this.each(function( j ) { jQuery( this ).removeClass( value.call(this, j, this.className) ); }); } if ( (value && typeof value === "string") || value === undefined ) { classNames = ( value || "" ).split( rspace ); for ( i = 0, l = this.length; i < l; i++ ) { elem = this[ i ]; if ( elem.nodeType === 1 && elem.className ) { if ( value ) { className = (" " + elem.className + " ").replace( rclass, " " ); for ( c = 0, cl = classNames.length; c < cl; c++ ) { className = className.replace(" " + classNames[ c ] + " ", " "); } elem.className = jQuery.trim( className ); } else { elem.className = ""; } } } } return this; }, toggleClass: function( value, stateVal ) { var type = typeof value, isBool = typeof stateVal === "boolean"; if ( jQuery.isFunction( value ) ) { return this.each(function( i ) { jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); }); } return this.each(function() { if ( type === "string" ) { // toggle individual class names var className, i = 0, self = jQuery( this ), state = stateVal, classNames = value.split( rspace ); while ( (className = classNames[ i++ ]) ) { // check each className given, space separated list state = isBool ? state : !self.hasClass( className ); self[ state ? "addClass" : "removeClass" ]( className ); } } else if ( type === "undefined" || type === "boolean" ) { if ( this.className ) { // store className if set jQuery._data( this, "__className__", this.className ); } // toggle whole className this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; } }); }, hasClass: function( selector ) { var className = " " + selector + " ", i = 0, l = this.length; for ( ; i < l; i++ ) { if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) > -1 ) { return true; } } return false; }, val: function( value ) { var hooks, ret, isFunction, elem = this[0]; if ( !arguments.length ) { if ( elem ) { hooks = jQuery.valHooks[ elem.nodeName.toLowerCase() ] || jQuery.valHooks[ elem.type ]; if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { return ret; } ret = elem.value; return typeof ret === "string" ? // handle most common string cases ret.replace(rreturn, "") : // handle cases where value is null/undef or number ret == null ? "" : ret; } return; } isFunction = jQuery.isFunction( value ); return this.each(function( i ) { var self = jQuery(this), val; if ( this.nodeType !== 1 ) { return; } if ( isFunction ) { val = value.call( this, i, self.val() ); } else { val = value; } // Treat null/undefined as ""; convert numbers to string if ( val == null ) { val = ""; } else if ( typeof val === "number" ) { val += ""; } else if ( jQuery.isArray( val ) ) { val = jQuery.map(val, function ( value ) { return value == null ? "" : value + ""; }); } hooks = jQuery.valHooks[ this.nodeName.toLowerCase() ] || jQuery.valHooks[ this.type ]; // If set returns undefined, fall back to normal setting if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { this.value = val; } }); } }); jQuery.extend({ valHooks: { option: { get: function( elem ) { // attributes.value is undefined in Blackberry 4.7 but // uses .value. See #6932 var val = elem.attributes.value; return !val || val.specified ? elem.value : elem.text; } }, select: { get: function( elem ) { var value, i, max, option, index = elem.selectedIndex, values = [], options = elem.options, one = elem.type === "select-one"; // Nothing was selected if ( index < 0 ) { return null; } // Loop through all the selected options i = one ? index : 0; max = one ? index + 1 : options.length; for ( ; i < max; i++ ) { option = options[ i ]; // Don't return options that are disabled or in a disabled optgroup if ( option.selected && (jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null) && (!option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" )) ) { // Get the specific value for the option value = jQuery( option ).val(); // We don't need an array for one selects if ( one ) { return value; } // Multi-Selects return an array values.push( value ); } } // Fixes Bug #2551 -- select.val() broken in IE after form.reset() if ( one && !values.length && options.length ) { return jQuery( options[ index ] ).val(); } return values; }, set: function( elem, value ) { var values = jQuery.makeArray( value ); jQuery(elem).find("option").each(function() { this.selected = jQuery.inArray( jQuery(this).val(), values ) >= 0; }); if ( !values.length ) { elem.selectedIndex = -1; } return values; } } }, attrFn: { val: true, css: true, html: true, text: true, data: true, width: true, height: true, offset: true }, attr: function( elem, name, value, pass ) { var ret, hooks, notxml, nType = elem.nodeType; // don't get/set attributes on text, comment and attribute nodes if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { return; } if ( pass && name in jQuery.attrFn ) { return jQuery( elem )[ name ]( value ); } // Fallback to prop when attributes are not supported if ( typeof elem.getAttribute === "undefined" ) { return jQuery.prop( elem, name, value ); } notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); // All attributes are lowercase // Grab necessary hook if one is defined if ( notxml ) { name = name.toLowerCase(); hooks = jQuery.attrHooks[ name ] || ( rboolean.test( name ) ? boolHook : nodeHook ); } if ( value !== undefined ) { if ( value === null ) { jQuery.removeAttr( elem, name ); return; } else if ( hooks && "set" in hooks && notxml && (ret = hooks.set( elem, value, name )) !== undefined ) { return ret; } else { elem.setAttribute( name, "" + value ); return value; } } else if ( hooks && "get" in hooks && notxml && (ret = hooks.get( elem, name )) !== null ) { return ret; } else { ret = elem.getAttribute( name ); // Non-existent attributes return null, we normalize to undefined return ret === null ? undefined : ret; } }, removeAttr: function( elem, value ) { var propName, attrNames, name, l, i = 0; if ( value && elem.nodeType === 1 ) { attrNames = value.toLowerCase().split( rspace ); l = attrNames.length; for ( ; i < l; i++ ) { name = attrNames[ i ]; if ( name ) { propName = jQuery.propFix[ name ] || name; // See #9699 for explanation of this approach (setting first, then removal) jQuery.attr( elem, name, "" ); elem.removeAttribute( getSetAttribute ? name : propName ); // Set corresponding property to false for boolean attributes if ( rboolean.test( name ) && propName in elem ) { elem[ propName ] = false; } } } } }, attrHooks: { type: { set: function( elem, value ) { // We can't allow the type property to be changed (since it causes problems in IE) if ( rtype.test( elem.nodeName ) && elem.parentNode ) { jQuery.error( "type property can't be changed" ); } else if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { // Setting the type on a radio button after the value resets the value in IE6-9 // Reset value to it's default in case type is set after value // This is for element creation var val = elem.value; elem.setAttribute( "type", value ); if ( val ) { elem.value = val; } return value; } } }, // Use the value property for back compat // Use the nodeHook for button elements in IE6/7 (#1954) value: { get: function( elem, name ) { if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { return nodeHook.get( elem, name ); } return name in elem ? elem.value : null; }, set: function( elem, value, name ) { if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { return nodeHook.set( elem, value, name ); } // Does not return so that setAttribute is also used elem.value = value; } } }, propFix: { tabindex: "tabIndex", readonly: "readOnly", "for": "htmlFor", "class": "className", maxlength: "maxLength", cellspacing: "cellSpacing", cellpadding: "cellPadding", rowspan: "rowSpan", colspan: "colSpan", usemap: "useMap", frameborder: "frameBorder", contenteditable: "contentEditable" }, prop: function( elem, name, value ) { var ret, hooks, notxml, nType = elem.nodeType; // don't get/set properties on text, comment and attribute nodes if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { return; } notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); if ( notxml ) { // Fix name and attach hooks name = jQuery.propFix[ name ] || name; hooks = jQuery.propHooks[ name ]; } if ( value !== undefined ) { if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { return ret; } else { return ( elem[ name ] = value ); } } else { if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { return ret; } else { return elem[ name ]; } } }, propHooks: { tabIndex: { get: function( elem ) { // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ var attributeNode = elem.getAttributeNode("tabindex"); return attributeNode && attributeNode.specified ? parseInt( attributeNode.value, 10 ) : rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? 0 : undefined; } } } }); // Add the tabIndex propHook to attrHooks for back-compat (different case is intentional) jQuery.attrHooks.tabindex = jQuery.propHooks.tabIndex; // Hook for boolean attributes boolHook = { get: function( elem, name ) { // Align boolean attributes with corresponding properties // Fall back to attribute presence where some booleans are not supported var attrNode, property = jQuery.prop( elem, name ); return property === true || typeof property !== "boolean" && ( attrNode = elem.getAttributeNode(name) ) && attrNode.nodeValue !== false ? name.toLowerCase() : undefined; }, set: function( elem, value, name ) { var propName; if ( value === false ) { // Remove boolean attributes when set to false jQuery.removeAttr( elem, name ); } else { // value is true since we know at this point it's type boolean and not false // Set boolean attributes to the same name and set the DOM property propName = jQuery.propFix[ name ] || name; if ( propName in elem ) { // Only set the IDL specifically if it already exists on the element elem[ propName ] = true; } elem.setAttribute( name, name.toLowerCase() ); } return name; } }; // IE6/7 do not support getting/setting some attributes with get/setAttribute if ( !getSetAttribute ) { fixSpecified = { name: true, id: true }; // Use this for any attribute in IE6/7 // This fixes almost every IE6/7 issue nodeHook = jQuery.valHooks.button = { get: function( elem, name ) { var ret; ret = elem.getAttributeNode( name ); return ret && ( fixSpecified[ name ] ? ret.nodeValue !== "" : ret.specified ) ? ret.nodeValue : undefined; }, set: function( elem, value, name ) { // Set the existing or create a new attribute node var ret = elem.getAttributeNode( name ); if ( !ret ) { ret = document.createAttribute( name ); elem.setAttributeNode( ret ); } return ( ret.nodeValue = value + "" ); } }; // Apply the nodeHook to tabindex jQuery.attrHooks.tabindex.set = nodeHook.set; // Set width and height to auto instead of 0 on empty string( Bug #8150 ) // This is for removals jQuery.each([ "width", "height" ], function( i, name ) { jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { set: function( elem, value ) { if ( value === "" ) { elem.setAttribute( name, "auto" ); return value; } } }); }); // Set contenteditable to false on removals(#10429) // Setting to empty string throws an error as an invalid value jQuery.attrHooks.contenteditable = { get: nodeHook.get, set: function( elem, value, name ) { if ( value === "" ) { value = "false"; } nodeHook.set( elem, value, name ); } }; } // Some attributes require a special call on IE if ( !jQuery.support.hrefNormalized ) { jQuery.each([ "href", "src", "width", "height" ], function( i, name ) { jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { get: function( elem ) { var ret = elem.getAttribute( name, 2 ); return ret === null ? undefined : ret; } }); }); } if ( !jQuery.support.style ) { jQuery.attrHooks.style = { get: function( elem ) { // Return undefined in the case of empty string // Normalize to lowercase since IE uppercases css property names return elem.style.cssText.toLowerCase() || undefined; }, set: function( elem, value ) { return ( elem.style.cssText = "" + value ); } }; } // Safari mis-reports the default selected property of an option // Accessing the parent's selectedIndex property fixes it if ( !jQuery.support.optSelected ) { jQuery.propHooks.selected = jQuery.extend( jQuery.propHooks.selected, { get: function( elem ) { var parent = elem.parentNode; if ( parent ) { parent.selectedIndex; // Make sure that it also works with optgroups, see #5701 if ( parent.parentNode ) { parent.parentNode.selectedIndex; } } return null; } }); } // IE6/7 call enctype encoding if ( !jQuery.support.enctype ) { jQuery.propFix.enctype = "encoding"; } // Radios and checkboxes getter/setter if ( !jQuery.support.checkOn ) { jQuery.each([ "radio", "checkbox" ], function() { jQuery.valHooks[ this ] = { get: function( elem ) { // Handle the case where in Webkit "" is returned instead of "on" if a value isn't specified return elem.getAttribute("value") === null ? "on" : elem.value; } }; }); } jQuery.each([ "radio", "checkbox" ], function() { jQuery.valHooks[ this ] = jQuery.extend( jQuery.valHooks[ this ], { set: function( elem, value ) { if ( jQuery.isArray( value ) ) { return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); } } }); }); var rformElems = /^(?:textarea|input|select)$/i, rtypenamespace = /^([^\.]*)?(?:\.(.+))?$/, rhoverHack = /\bhover(\.\S+)?\b/, rkeyEvent = /^key/, rmouseEvent = /^(?:mouse|contextmenu)|click/, rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, rquickIs = /^(\w*)(?:#([\w\-]+))?(?:\.([\w\-]+))?$/, quickParse = function( selector ) { var quick = rquickIs.exec( selector ); if ( quick ) { // 0 1 2 3 // [ _, tag, id, class ] quick[1] = ( quick[1] || "" ).toLowerCase(); quick[3] = quick[3] && new RegExp( "(?:^|\\s)" + quick[3] + "(?:\\s|$)" ); } return quick; }, quickIs = function( elem, m ) { var attrs = elem.attributes || {}; return ( (!m[1] || elem.nodeName.toLowerCase() === m[1]) && (!m[2] || (attrs.id || {}).value === m[2]) && (!m[3] || m[3].test( (attrs[ "class" ] || {}).value )) ); }, hoverHack = function( events ) { return jQuery.event.special.hover ? events : events.replace( rhoverHack, "mouseenter$1 mouseleave$1" ); }; /* * Helper functions for managing events -- not part of the public interface. * Props to Dean Edwards' addEvent library for many of the ideas. */ jQuery.event = { add: function( elem, types, handler, data, selector ) { var elemData, eventHandle, events, t, tns, type, namespaces, handleObj, handleObjIn, quick, handlers, special; // Don't attach events to noData or text/comment nodes (allow plain objects tho) if ( elem.nodeType === 3 || elem.nodeType === 8 || !types || !handler || !(elemData = jQuery._data( elem )) ) { return; } // Caller can pass in an object of custom data in lieu of the handler if ( handler.handler ) { handleObjIn = handler; handler = handleObjIn.handler; } // Make sure that the handler has a unique ID, used to find/remove it later if ( !handler.guid ) { handler.guid = jQuery.guid++; } // Init the element's event structure and main handler, if this is the first events = elemData.events; if ( !events ) { elemData.events = events = {}; } eventHandle = elemData.handle; if ( !eventHandle ) { elemData.handle = eventHandle = function( e ) { // Discard the second event of a jQuery.event.trigger() and // when an event is called after a page has unloaded return typeof jQuery !== "undefined" && (!e || jQuery.event.triggered !== e.type) ? jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : undefined; }; // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events eventHandle.elem = elem; } // Handle multiple events separated by a space // jQuery(...).bind("mouseover mouseout", fn); types = jQuery.trim( hoverHack(types) ).split( " " ); for ( t = 0; t < types.length; t++ ) { tns = rtypenamespace.exec( types[t] ) || []; type = tns[1]; namespaces = ( tns[2] || "" ).split( "." ).sort(); // If event changes its type, use the special event handlers for the changed type special = jQuery.event.special[ type ] || {}; // If selector defined, determine special event api type, otherwise given type type = ( selector ? special.delegateType : special.bindType ) || type; // Update special based on newly reset type special = jQuery.event.special[ type ] || {}; // handleObj is passed to all event handlers handleObj = jQuery.extend({ type: type, origType: tns[1], data: data, handler: handler, guid: handler.guid, selector: selector, quick: quickParse( selector ), namespace: namespaces.join(".") }, handleObjIn ); // Init the event handler queue if we're the first handlers = events[ type ]; if ( !handlers ) { handlers = events[ type ] = []; handlers.delegateCount = 0; // Only use addEventListener/attachEvent if the special events handler returns false if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { // Bind the global event handler to the element if ( elem.addEventListener ) { elem.addEventListener( type, eventHandle, false ); } else if ( elem.attachEvent ) { elem.attachEvent( "on" + type, eventHandle ); } } } if ( special.add ) { special.add.call( elem, handleObj ); if ( !handleObj.handler.guid ) { handleObj.handler.guid = handler.guid; } } // Add to the element's handler list, delegates in front if ( selector ) { handlers.splice( handlers.delegateCount++, 0, handleObj ); } else { handlers.push( handleObj ); } // Keep track of which events have ever been used, for event optimization jQuery.event.global[ type ] = true; } // Nullify elem to prevent memory leaks in IE elem = null; }, global: {}, // Detach an event or set of events from an element remove: function( elem, types, handler, selector, mappedTypes ) { var elemData = jQuery.hasData( elem ) && jQuery._data( elem ), t, tns, type, origType, namespaces, origCount, j, events, special, handle, eventType, handleObj; if ( !elemData || !(events = elemData.events) ) { return; } // Once for each type.namespace in types; type may be omitted types = jQuery.trim( hoverHack( types || "" ) ).split(" "); for ( t = 0; t < types.length; t++ ) { tns = rtypenamespace.exec( types[t] ) || []; type = origType = tns[1]; namespaces = tns[2]; // Unbind all events (on this namespace, if provided) for the element if ( !type ) { for ( type in events ) { jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); } continue; } special = jQuery.event.special[ type ] || {}; type = ( selector? special.delegateType : special.bindType ) || type; eventType = events[ type ] || []; origCount = eventType.length; namespaces = namespaces ? new RegExp("(^|\\.)" + namespaces.split(".").sort().join("\\.(?:.*\\.)?") + "(\\.|$)") : null; // Remove matching events for ( j = 0; j < eventType.length; j++ ) { handleObj = eventType[ j ]; if ( ( mappedTypes || origType === handleObj.origType ) && ( !handler || handler.guid === handleObj.guid ) && ( !namespaces || namespaces.test( handleObj.namespace ) ) && ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { eventType.splice( j--, 1 ); if ( handleObj.selector ) { eventType.delegateCount--; } if ( special.remove ) { special.remove.call( elem, handleObj ); } } } // Remove generic event handler if we removed something and no more handlers exist // (avoids potential for endless recursion during removal of special event handlers) if ( eventType.length === 0 && origCount !== eventType.length ) { if ( !special.teardown || special.teardown.call( elem, namespaces ) === false ) { jQuery.removeEvent( elem, type, elemData.handle ); } delete events[ type ]; } } // Remove the expando if it's no longer used if ( jQuery.isEmptyObject( events ) ) { handle = elemData.handle; if ( handle ) { handle.elem = null; } // removeData also checks for emptiness and clears the expando if empty // so use it instead of delete jQuery.removeData( elem, [ "events", "handle" ], true ); } }, // Events that are safe to short-circuit if no handlers are attached. // Native DOM events should not be added, they may have inline handlers. customEvent: { "getData": true, "setData": true, "changeData": true }, trigger: function( event, data, elem, onlyHandlers ) { // Don't do events on text and comment nodes if ( elem && (elem.nodeType === 3 || elem.nodeType === 8) ) { return; } // Event object or event type var type = event.type || event, namespaces = [], cache, exclusive, i, cur, old, ontype, special, handle, eventPath, bubbleType; // focus/blur morphs to focusin/out; ensure we're not firing them right now if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { return; } if ( type.indexOf( "!" ) >= 0 ) { // Exclusive events trigger only for the exact event (no namespaces) type = type.slice(0, -1); exclusive = true; } if ( type.indexOf( "." ) >= 0 ) { // Namespaced trigger; create a regexp to match event type in handle() namespaces = type.split("."); type = namespaces.shift(); namespaces.sort(); } if ( (!elem || jQuery.event.customEvent[ type ]) && !jQuery.event.global[ type ] ) { // No jQuery handlers for this event type, and it can't have inline handlers return; } // Caller can pass in an Event, Object, or just an event type string event = typeof event === "object" ? // jQuery.Event object event[ jQuery.expando ] ? event : // Object literal new jQuery.Event( type, event ) : // Just the event type (string) new jQuery.Event( type ); event.type = type; event.isTrigger = true; event.exclusive = exclusive; event.namespace = namespaces.join( "." ); event.namespace_re = event.namespace? new RegExp("(^|\\.)" + namespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") : null; ontype = type.indexOf( ":" ) < 0 ? "on" + type : ""; // Handle a global trigger if ( !elem ) { // TODO: Stop taunting the data cache; remove global events and always attach to document cache = jQuery.cache; for ( i in cache ) { if ( cache[ i ].events && cache[ i ].events[ type ] ) { jQuery.event.trigger( event, data, cache[ i ].handle.elem, true ); } } return; } // Clean up the event in case it is being reused event.result = undefined; if ( !event.target ) { event.target = elem; } // Clone any incoming data and prepend the event, creating the handler arg list data = data != null ? jQuery.makeArray( data ) : []; data.unshift( event ); // Allow special events to draw outside the lines special = jQuery.event.special[ type ] || {}; if ( special.trigger && special.trigger.apply( elem, data ) === false ) { return; } // Determine event propagation path in advance, per W3C events spec (#9951) // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) eventPath = [[ elem, special.bindType || type ]]; if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { bubbleType = special.delegateType || type; cur = rfocusMorph.test( bubbleType + type ) ? elem : elem.parentNode; old = null; for ( ; cur; cur = cur.parentNode ) { eventPath.push([ cur, bubbleType ]); old = cur; } // Only add window if we got to document (e.g., not plain obj or detached DOM) if ( old && old === elem.ownerDocument ) { eventPath.push([ old.defaultView || old.parentWindow || window, bubbleType ]); } } // Fire handlers on the event path for ( i = 0; i < eventPath.length && !event.isPropagationStopped(); i++ ) { cur = eventPath[i][0]; event.type = eventPath[i][1]; handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); if ( handle ) { handle.apply( cur, data ); } // Note that this is a bare JS function and not a jQuery handler handle = ontype && cur[ ontype ]; if ( handle && jQuery.acceptData( cur ) && handle.apply( cur, data ) === false ) { event.preventDefault(); } } event.type = type; // If nobody prevented the default action, do it now if ( !onlyHandlers && !event.isDefaultPrevented() ) { if ( (!special._default || special._default.apply( elem.ownerDocument, data ) === false) && !(type === "click" && jQuery.nodeName( elem, "a" )) && jQuery.acceptData( elem ) ) { // Call a native DOM method on the target with the same name name as the event. // Can't use an .isFunction() check here because IE6/7 fails that test. // Don't do default actions on window, that's where global variables be (#6170) // IE<9 dies on focus/blur to hidden element (#1486) if ( ontype && elem[ type ] && ((type !== "focus" && type !== "blur") || event.target.offsetWidth !== 0) && !jQuery.isWindow( elem ) ) { // Don't re-trigger an onFOO event when we call its FOO() method old = elem[ ontype ]; if ( old ) { elem[ ontype ] = null; } // Prevent re-triggering of the same event, since we already bubbled it above jQuery.event.triggered = type; elem[ type ](); jQuery.event.triggered = undefined; if ( old ) { elem[ ontype ] = old; } } } } return event.result; }, dispatch: function( event ) { // Make a writable jQuery.Event from the native event object event = jQuery.event.fix( event || window.event ); var handlers = ( (jQuery._data( this, "events" ) || {} )[ event.type ] || []), delegateCount = handlers.delegateCount, args = [].slice.call( arguments, 0 ), run_all = !event.exclusive && !event.namespace, handlerQueue = [], i, j, cur, jqcur, ret, selMatch, matched, matches, handleObj, sel, related; // Use the fix-ed jQuery.Event rather than the (read-only) native event args[0] = event; event.delegateTarget = this; // Determine handlers that should run if there are delegated events // Avoid disabled elements in IE (#6911) and non-left-click bubbling in Firefox (#3861) if ( delegateCount && !event.target.disabled && !(event.button && event.type === "click") ) { // Pregenerate a single jQuery object for reuse with .is() jqcur = jQuery(this); jqcur.context = this.ownerDocument || this; for ( cur = event.target; cur != this; cur = cur.parentNode || this ) { selMatch = {}; matches = []; jqcur[0] = cur; for ( i = 0; i < delegateCount; i++ ) { handleObj = handlers[ i ]; sel = handleObj.selector; if ( selMatch[ sel ] === undefined ) { selMatch[ sel ] = ( handleObj.quick ? quickIs( cur, handleObj.quick ) : jqcur.is( sel ) ); } if ( selMatch[ sel ] ) { matches.push( handleObj ); } } if ( matches.length ) { handlerQueue.push({ elem: cur, matches: matches }); } } } // Add the remaining (directly-bound) handlers if ( handlers.length > delegateCount ) { handlerQueue.push({ elem: this, matches: handlers.slice( delegateCount ) }); } // Run delegates first; they may want to stop propagation beneath us for ( i = 0; i < handlerQueue.length && !event.isPropagationStopped(); i++ ) { matched = handlerQueue[ i ]; event.currentTarget = matched.elem; for ( j = 0; j < matched.matches.length && !event.isImmediatePropagationStopped(); j++ ) { handleObj = matched.matches[ j ]; // Triggered event must either 1) be non-exclusive and have no namespace, or // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). if ( run_all || (!event.namespace && !handleObj.namespace) || event.namespace_re && event.namespace_re.test( handleObj.namespace ) ) { event.data = handleObj.data; event.handleObj = handleObj; ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) .apply( matched.elem, args ); if ( ret !== undefined ) { event.result = ret; if ( ret === false ) { event.preventDefault(); event.stopPropagation(); } } } } } return event.result; }, // Includes some event props shared by KeyEvent and MouseEvent // *** attrChange attrName relatedNode srcElement are not normalized, non-W3C, deprecated, will be removed in 1.8 *** props: "attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), fixHooks: {}, keyHooks: { props: "char charCode key keyCode".split(" "), filter: function( event, original ) { // Add which for key events if ( event.which == null ) { event.which = original.charCode != null ? original.charCode : original.keyCode; } return event; } }, mouseHooks: { props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), filter: function( event, original ) { var eventDoc, doc, body, button = original.button, fromElement = original.fromElement; // Calculate pageX/Y if missing and clientX/Y available if ( event.pageX == null && original.clientX != null ) { eventDoc = event.target.ownerDocument || document; doc = eventDoc.documentElement; body = eventDoc.body; event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); } // Add relatedTarget, if necessary if ( !event.relatedTarget && fromElement ) { event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; } // Add which for click: 1 === left; 2 === middle; 3 === right // Note: button is not normalized, so don't use it if ( !event.which && button !== undefined ) { event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); } return event; } }, fix: function( event ) { if ( event[ jQuery.expando ] ) { return event; } // Create a writable copy of the event object and normalize some properties var i, prop, originalEvent = event, fixHook = jQuery.event.fixHooks[ event.type ] || {}, copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; event = jQuery.Event( originalEvent ); for ( i = copy.length; i; ) { prop = copy[ --i ]; event[ prop ] = originalEvent[ prop ]; } // Fix target property, if necessary (#1925, IE 6/7/8 & Safari2) if ( !event.target ) { event.target = originalEvent.srcElement || document; } // Target should not be a text node (#504, Safari) if ( event.target.nodeType === 3 ) { event.target = event.target.parentNode; } // For mouse/key events; add metaKey if it's not there (#3368, IE6/7/8) if ( event.metaKey === undefined ) { event.metaKey = event.ctrlKey; } return fixHook.filter? fixHook.filter( event, originalEvent ) : event; }, special: { ready: { // Make sure the ready event is setup setup: jQuery.bindReady }, load: { // Prevent triggered image.load events from bubbling to window.load noBubble: true }, focus: { delegateType: "focusin" }, blur: { delegateType: "focusout" }, beforeunload: { setup: function( data, namespaces, eventHandle ) { // We only want to do this special case on windows if ( jQuery.isWindow( this ) ) { this.onbeforeunload = eventHandle; } }, teardown: function( namespaces, eventHandle ) { if ( this.onbeforeunload === eventHandle ) { this.onbeforeunload = null; } } } }, simulate: function( type, elem, event, bubble ) { // Piggyback on a donor event to simulate a different one. // Fake originalEvent to avoid donor's stopPropagation, but if the // simulated event prevents default then we do the same on the donor. var e = jQuery.extend( new jQuery.Event(), event, { type: type, isSimulated: true, originalEvent: {} } ); if ( bubble ) { jQuery.event.trigger( e, null, elem ); } else { jQuery.event.dispatch.call( elem, e ); } if ( e.isDefaultPrevented() ) { event.preventDefault(); } } }; // Some plugins are using, but it's undocumented/deprecated and will be removed. // The 1.7 special event interface should provide all the hooks needed now. jQuery.event.handle = jQuery.event.dispatch; jQuery.removeEvent = document.removeEventListener ? function( elem, type, handle ) { if ( elem.removeEventListener ) { elem.removeEventListener( type, handle, false ); } } : function( elem, type, handle ) { if ( elem.detachEvent ) { elem.detachEvent( "on" + type, handle ); } }; jQuery.Event = function( src, props ) { // Allow instantiation without the 'new' keyword if ( !(this instanceof jQuery.Event) ) { return new jQuery.Event( src, props ); } // Event object if ( src && src.type ) { this.originalEvent = src; this.type = src.type; // Events bubbling up the document may have been marked as prevented // by a handler lower down the tree; reflect the correct value. this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false || src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse; // Event type } else { this.type = src; } // Put explicitly provided properties onto the event object if ( props ) { jQuery.extend( this, props ); } // Create a timestamp if incoming event doesn't have one this.timeStamp = src && src.timeStamp || jQuery.now(); // Mark it as fixed this[ jQuery.expando ] = true; }; function returnFalse() { return false; } function returnTrue() { return true; } // jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding // http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html jQuery.Event.prototype = { preventDefault: function() { this.isDefaultPrevented = returnTrue; var e = this.originalEvent; if ( !e ) { return; } // if preventDefault exists run it on the original event if ( e.preventDefault ) { e.preventDefault(); // otherwise set the returnValue property of the original event to false (IE) } else { e.returnValue = false; } }, stopPropagation: function() { this.isPropagationStopped = returnTrue; var e = this.originalEvent; if ( !e ) { return; } // if stopPropagation exists run it on the original event if ( e.stopPropagation ) { e.stopPropagation(); } // otherwise set the cancelBubble property of the original event to true (IE) e.cancelBubble = true; }, stopImmediatePropagation: function() { this.isImmediatePropagationStopped = returnTrue; this.stopPropagation(); }, isDefaultPrevented: returnFalse, isPropagationStopped: returnFalse, isImmediatePropagationStopped: returnFalse }; // Create mouseenter/leave events using mouseover/out and event-time checks jQuery.each({ mouseenter: "mouseover", mouseleave: "mouseout" }, function( orig, fix ) { jQuery.event.special[ orig ] = { delegateType: fix, bindType: fix, handle: function( event ) { var target = this, related = event.relatedTarget, handleObj = event.handleObj, selector = handleObj.selector, ret; // For mousenter/leave call the handler if related is outside the target. // NB: No relatedTarget if the mouse left/entered the browser window if ( !related || (related !== target && !jQuery.contains( target, related )) ) { event.type = handleObj.origType; ret = handleObj.handler.apply( this, arguments ); event.type = fix; } return ret; } }; }); // IE submit delegation if ( !jQuery.support.submitBubbles ) { jQuery.event.special.submit = { setup: function() { // Only need this for delegated form submit events if ( jQuery.nodeName( this, "form" ) ) { return false; } // Lazy-add a submit handler when a descendant form may potentially be submitted jQuery.event.add( this, "click._submit keypress._submit", function( e ) { // Node name check avoids a VML-related crash in IE (#9807) var elem = e.target, form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; if ( form && !form._submit_attached ) { jQuery.event.add( form, "submit._submit", function( event ) { // If form was submitted by the user, bubble the event up the tree if ( this.parentNode && !event.isTrigger ) { jQuery.event.simulate( "submit", this.parentNode, event, true ); } }); form._submit_attached = true; } }); // return undefined since we don't need an event listener }, teardown: function() { // Only need this for delegated form submit events if ( jQuery.nodeName( this, "form" ) ) { return false; } // Remove delegated handlers; cleanData eventually reaps submit handlers attached above jQuery.event.remove( this, "._submit" ); } }; } // IE change delegation and checkbox/radio fix if ( !jQuery.support.changeBubbles ) { jQuery.event.special.change = { setup: function() { if ( rformElems.test( this.nodeName ) ) { // IE doesn't fire change on a check/radio until blur; trigger it on click // after a propertychange. Eat the blur-change in special.change.handle. // This still fires onchange a second time for check/radio after blur. if ( this.type === "checkbox" || this.type === "radio" ) { jQuery.event.add( this, "propertychange._change", function( event ) { if ( event.originalEvent.propertyName === "checked" ) { this._just_changed = true; } }); jQuery.event.add( this, "click._change", function( event ) { if ( this._just_changed && !event.isTrigger ) { this._just_changed = false; jQuery.event.simulate( "change", this, event, true ); } }); } return false; } // Delegated event; lazy-add a change handler on descendant inputs jQuery.event.add( this, "beforeactivate._change", function( e ) { var elem = e.target; if ( rformElems.test( elem.nodeName ) && !elem._change_attached ) { jQuery.event.add( elem, "change._change", function( event ) { if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { jQuery.event.simulate( "change", this.parentNode, event, true ); } }); elem._change_attached = true; } }); }, handle: function( event ) { var elem = event.target; // Swallow native change events from checkbox/radio, we already triggered them above if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { return event.handleObj.handler.apply( this, arguments ); } }, teardown: function() { jQuery.event.remove( this, "._change" ); return rformElems.test( this.nodeName ); } }; } // Create "bubbling" focus and blur events if ( !jQuery.support.focusinBubbles ) { jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { // Attach a single capturing handler while someone wants focusin/focusout var attaches = 0, handler = function( event ) { jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); }; jQuery.event.special[ fix ] = { setup: function() { if ( attaches++ === 0 ) { document.addEventListener( orig, handler, true ); } }, teardown: function() { if ( --attaches === 0 ) { document.removeEventListener( orig, handler, true ); } } }; }); } jQuery.fn.extend({ on: function( types, selector, data, fn, /*INTERNAL*/ one ) { var origFn, type; // Types can be a map of types/handlers if ( typeof types === "object" ) { // ( types-Object, selector, data ) if ( typeof selector !== "string" ) { // ( types-Object, data ) data = selector; selector = undefined; } for ( type in types ) { this.on( type, selector, data, types[ type ], one ); } return this; } if ( data == null && fn == null ) { // ( types, fn ) fn = selector; data = selector = undefined; } else if ( fn == null ) { if ( typeof selector === "string" ) { // ( types, selector, fn ) fn = data; data = undefined; } else { // ( types, data, fn ) fn = data; data = selector; selector = undefined; } } if ( fn === false ) { fn = returnFalse; } else if ( !fn ) { return this; } if ( one === 1 ) { origFn = fn; fn = function( event ) { // Can use an empty set, since event contains the info jQuery().off( event ); return origFn.apply( this, arguments ); }; // Use same guid so caller can remove using origFn fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); } return this.each( function() { jQuery.event.add( this, types, fn, data, selector ); }); }, one: function( types, selector, data, fn ) { return this.on.call( this, types, selector, data, fn, 1 ); }, off: function( types, selector, fn ) { if ( types && types.preventDefault && types.handleObj ) { // ( event ) dispatched jQuery.Event var handleObj = types.handleObj; jQuery( types.delegateTarget ).off( handleObj.namespace? handleObj.type + "." + handleObj.namespace : handleObj.type, handleObj.selector, handleObj.handler ); return this; } if ( typeof types === "object" ) { // ( types-object [, selector] ) for ( var type in types ) { this.off( type, selector, types[ type ] ); } return this; } if ( selector === false || typeof selector === "function" ) { // ( types [, fn] ) fn = selector; selector = undefined; } if ( fn === false ) { fn = returnFalse; } return this.each(function() { jQuery.event.remove( this, types, fn, selector ); }); }, bind: function( types, data, fn ) { return this.on( types, null, data, fn ); }, unbind: function( types, fn ) { return this.off( types, null, fn ); }, live: function( types, data, fn ) { jQuery( this.context ).on( types, this.selector, data, fn ); return this; }, die: function( types, fn ) { jQuery( this.context ).off( types, this.selector || "**", fn ); return this; }, delegate: function( selector, types, data, fn ) { return this.on( types, selector, data, fn ); }, undelegate: function( selector, types, fn ) { // ( namespace ) or ( selector, types [, fn] ) return arguments.length == 1? this.off( selector, "**" ) : this.off( types, selector, fn ); }, trigger: function( type, data ) { return this.each(function() { jQuery.event.trigger( type, data, this ); }); }, triggerHandler: function( type, data ) { if ( this[0] ) { return jQuery.event.trigger( type, data, this[0], true ); } }, toggle: function( fn ) { // Save reference to arguments for access in closure var args = arguments, guid = fn.guid || jQuery.guid++, i = 0, toggler = function( event ) { // Figure out which function to execute var lastToggle = ( jQuery._data( this, "lastToggle" + fn.guid ) || 0 ) % i; jQuery._data( this, "lastToggle" + fn.guid, lastToggle + 1 ); // Make sure that clicks stop event.preventDefault(); // and execute the function return args[ lastToggle ].apply( this, arguments ) || false; }; // link all the functions, so any of them can unbind this click handler toggler.guid = guid; while ( i < args.length ) { args[ i++ ].guid = guid; } return this.click( toggler ); }, hover: function( fnOver, fnOut ) { return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); } }); jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " + "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + "change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) { // Handle event binding jQuery.fn[ name ] = function( data, fn ) { if ( fn == null ) { fn = data; data = null; } return arguments.length > 0 ? this.on( name, null, data, fn ) : this.trigger( name ); }; if ( jQuery.attrFn ) { jQuery.attrFn[ name ] = true; } if ( rkeyEvent.test( name ) ) { jQuery.event.fixHooks[ name ] = jQuery.event.keyHooks; } if ( rmouseEvent.test( name ) ) { jQuery.event.fixHooks[ name ] = jQuery.event.mouseHooks; } }); /*! * Sizzle CSS Selector Engine * Copyright 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * More information: http://sizzlejs.com/ */ (function(){ var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, expando = "sizcache" + (Math.random() + '').replace('.', ''), done = 0, toString = Object.prototype.toString, hasDuplicate = false, baseHasDuplicate = true, rBackslash = /\\/g, rReturn = /\r\n/g, rNonWord = /\W/; // Here we check if the JavaScript engine is using some sort of // optimization where it does not always call our comparison // function. If that is the case, discard the hasDuplicate value. // Thus far that includes Google Chrome. [0, 0].sort(function() { baseHasDuplicate = false; return 0; }); var Sizzle = function( selector, context, results, seed ) { results = results || []; context = context || document; var origContext = context; if ( context.nodeType !== 1 && context.nodeType !== 9 ) { return []; } if ( !selector || typeof selector !== "string" ) { return results; } var m, set, checkSet, extra, ret, cur, pop, i, prune = true, contextXML = Sizzle.isXML( context ), parts = [], soFar = selector; // Reset the position of the chunker regexp (start from head) do { chunker.exec( "" ); m = chunker.exec( soFar ); if ( m ) { soFar = m[3]; parts.push( m[1] ); if ( m[2] ) { extra = m[3]; break; } } } while ( m ); if ( parts.length > 1 && origPOS.exec( selector ) ) { if ( parts.length === 2 && Expr.relative[ parts[0] ] ) { set = posProcess( parts[0] + parts[1], context, seed ); } else { set = Expr.relative[ parts[0] ] ? [ context ] : Sizzle( parts.shift(), context ); while ( parts.length ) { selector = parts.shift(); if ( Expr.relative[ selector ] ) { selector += parts.shift(); } set = posProcess( selector, set, seed ); } } } else { // Take a shortcut and set the context if the root selector is an ID // (but not if it'll be faster if the inner selector is an ID) if ( !seed && parts.length > 1 && context.nodeType === 9 && !contextXML && Expr.match.ID.test(parts[0]) && !Expr.match.ID.test(parts[parts.length - 1]) ) { ret = Sizzle.find( parts.shift(), context, contextXML ); context = ret.expr ? Sizzle.filter( ret.expr, ret.set )[0] : ret.set[0]; } if ( context ) { ret = seed ? { expr: parts.pop(), set: makeArray(seed) } : Sizzle.find( parts.pop(), parts.length === 1 && (parts[0] === "~" || parts[0] === "+") && context.parentNode ? context.parentNode : context, contextXML ); set = ret.expr ? Sizzle.filter( ret.expr, ret.set ) : ret.set; if ( parts.length > 0 ) { checkSet = makeArray( set ); } else { prune = false; } while ( parts.length ) { cur = parts.pop(); pop = cur; if ( !Expr.relative[ cur ] ) { cur = ""; } else { pop = parts.pop(); } if ( pop == null ) { pop = context; } Expr.relative[ cur ]( checkSet, pop, contextXML ); } } else { checkSet = parts = []; } } if ( !checkSet ) { checkSet = set; } if ( !checkSet ) { Sizzle.error( cur || selector ); } if ( toString.call(checkSet) === "[object Array]" ) { if ( !prune ) { results.push.apply( results, checkSet ); } else if ( context && context.nodeType === 1 ) { for ( i = 0; checkSet[i] != null; i++ ) { if ( checkSet[i] && (checkSet[i] === true || checkSet[i].nodeType === 1 && Sizzle.contains(context, checkSet[i])) ) { results.push( set[i] ); } } } else { for ( i = 0; checkSet[i] != null; i++ ) { if ( checkSet[i] && checkSet[i].nodeType === 1 ) { results.push( set[i] ); } } } } else { makeArray( checkSet, results ); } if ( extra ) { Sizzle( extra, origContext, results, seed ); Sizzle.uniqueSort( results ); } return results; }; Sizzle.uniqueSort = function( results ) { if ( sortOrder ) { hasDuplicate = baseHasDuplicate; results.sort( sortOrder ); if ( hasDuplicate ) { for ( var i = 1; i < results.length; i++ ) { if ( results[i] === results[ i - 1 ] ) { results.splice( i--, 1 ); } } } } return results; }; Sizzle.matches = function( expr, set ) { return Sizzle( expr, null, null, set ); }; Sizzle.matchesSelector = function( node, expr ) { return Sizzle( expr, null, null, [node] ).length > 0; }; Sizzle.find = function( expr, context, isXML ) { var set, i, len, match, type, left; if ( !expr ) { return []; } for ( i = 0, len = Expr.order.length; i < len; i++ ) { type = Expr.order[i]; if ( (match = Expr.leftMatch[ type ].exec( expr )) ) { left = match[1]; match.splice( 1, 1 ); if ( left.substr( left.length - 1 ) !== "\\" ) { match[1] = (match[1] || "").replace( rBackslash, "" ); set = Expr.find[ type ]( match, context, isXML ); if ( set != null ) { expr = expr.replace( Expr.match[ type ], "" ); break; } } } } if ( !set ) { set = typeof context.getElementsByTagName !== "undefined" ? context.getElementsByTagName( "*" ) : []; } return { set: set, expr: expr }; }; Sizzle.filter = function( expr, set, inplace, not ) { var match, anyFound, type, found, item, filter, left, i, pass, old = expr, result = [], curLoop = set, isXMLFilter = set && set[0] && Sizzle.isXML( set[0] ); while ( expr && set.length ) { for ( type in Expr.filter ) { if ( (match = Expr.leftMatch[ type ].exec( expr )) != null && match[2] ) { filter = Expr.filter[ type ]; left = match[1]; anyFound = false; match.splice(1,1); if ( left.substr( left.length - 1 ) === "\\" ) { continue; } if ( curLoop === result ) { result = []; } if ( Expr.preFilter[ type ] ) { match = Expr.preFilter[ type ]( match, curLoop, inplace, result, not, isXMLFilter ); if ( !match ) { anyFound = found = true; } else if ( match === true ) { continue; } } if ( match ) { for ( i = 0; (item = curLoop[i]) != null; i++ ) { if ( item ) { found = filter( item, match, i, curLoop ); pass = not ^ found; if ( inplace && found != null ) { if ( pass ) { anyFound = true; } else { curLoop[i] = false; } } else if ( pass ) { result.push( item ); anyFound = true; } } } } if ( found !== undefined ) { if ( !inplace ) { curLoop = result; } expr = expr.replace( Expr.match[ type ], "" ); if ( !anyFound ) { return []; } break; } } } // Improper expression if ( expr === old ) { if ( anyFound == null ) { Sizzle.error( expr ); } else { break; } } old = expr; } return curLoop; }; Sizzle.error = function( msg ) { throw new Error( "Syntax error, unrecognized expression: " + msg ); }; /** * Utility function for retrieving the text value of an array of DOM nodes * @param {Array|Element} elem */ var getText = Sizzle.getText = function( elem ) { var i, node, nodeType = elem.nodeType, ret = ""; if ( nodeType ) { if ( nodeType === 1 || nodeType === 9 ) { // Use textContent || innerText for elements if ( typeof elem.textContent === 'string' ) { return elem.textContent; } else if ( typeof elem.innerText === 'string' ) { // Replace IE's carriage returns return elem.innerText.replace( rReturn, '' ); } else { // Traverse it's children for ( elem = elem.firstChild; elem; elem = elem.nextSibling) { ret += getText( elem ); } } } else if ( nodeType === 3 || nodeType === 4 ) { return elem.nodeValue; } } else { // If no nodeType, this is expected to be an array for ( i = 0; (node = elem[i]); i++ ) { // Do not traverse comment nodes if ( node.nodeType !== 8 ) { ret += getText( node ); } } } return ret; }; var Expr = Sizzle.selectors = { order: [ "ID", "NAME", "TAG" ], match: { ID: /#((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, CLASS: /\.((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, NAME: /\[name=['"]*((?:[\w\u00c0-\uFFFF\-]|\\.)+)['"]*\]/, ATTR: /\[\s*((?:[\w\u00c0-\uFFFF\-]|\\.)+)\s*(?:(\S?=)\s*(?:(['"])(.*?)\3|(#?(?:[\w\u00c0-\uFFFF\-]|\\.)*)|)|)\s*\]/, TAG: /^((?:[\w\u00c0-\uFFFF\*\-]|\\.)+)/, CHILD: /:(only|nth|last|first)-child(?:\(\s*(even|odd|(?:[+\-]?\d+|(?:[+\-]?\d*)?n\s*(?:[+\-]\s*\d+)?))\s*\))?/, POS: /:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^\-]|$)/, PSEUDO: /:((?:[\w\u00c0-\uFFFF\-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/ }, leftMatch: {}, attrMap: { "class": "className", "for": "htmlFor" }, attrHandle: { href: function( elem ) { return elem.getAttribute( "href" ); }, type: function( elem ) { return elem.getAttribute( "type" ); } }, relative: { "+": function(checkSet, part){ var isPartStr = typeof part === "string", isTag = isPartStr && !rNonWord.test( part ), isPartStrNotTag = isPartStr && !isTag; if ( isTag ) { part = part.toLowerCase(); } for ( var i = 0, l = checkSet.length, elem; i < l; i++ ) { if ( (elem = checkSet[i]) ) { while ( (elem = elem.previousSibling) && elem.nodeType !== 1 ) {} checkSet[i] = isPartStrNotTag || elem && elem.nodeName.toLowerCase() === part ? elem || false : elem === part; } } if ( isPartStrNotTag ) { Sizzle.filter( part, checkSet, true ); } }, ">": function( checkSet, part ) { var elem, isPartStr = typeof part === "string", i = 0, l = checkSet.length; if ( isPartStr && !rNonWord.test( part ) ) { part = part.toLowerCase(); for ( ; i < l; i++ ) { elem = checkSet[i]; if ( elem ) { var parent = elem.parentNode; checkSet[i] = parent.nodeName.toLowerCase() === part ? parent : false; } } } else { for ( ; i < l; i++ ) { elem = checkSet[i]; if ( elem ) { checkSet[i] = isPartStr ? elem.parentNode : elem.parentNode === part; } } if ( isPartStr ) { Sizzle.filter( part, checkSet, true ); } } }, "": function(checkSet, part, isXML){ var nodeCheck, doneName = done++, checkFn = dirCheck; if ( typeof part === "string" && !rNonWord.test( part ) ) { part = part.toLowerCase(); nodeCheck = part; checkFn = dirNodeCheck; } checkFn( "parentNode", part, doneName, checkSet, nodeCheck, isXML ); }, "~": function( checkSet, part, isXML ) { var nodeCheck, doneName = done++, checkFn = dirCheck; if ( typeof part === "string" && !rNonWord.test( part ) ) { part = part.toLowerCase(); nodeCheck = part; checkFn = dirNodeCheck; } checkFn( "previousSibling", part, doneName, checkSet, nodeCheck, isXML ); } }, find: { ID: function( match, context, isXML ) { if ( typeof context.getElementById !== "undefined" && !isXML ) { var m = context.getElementById(match[1]); // Check parentNode to catch when Blackberry 4.6 returns // nodes that are no longer in the document #6963 return m && m.parentNode ? [m] : []; } }, NAME: function( match, context ) { if ( typeof context.getElementsByName !== "undefined" ) { var ret = [], results = context.getElementsByName( match[1] ); for ( var i = 0, l = results.length; i < l; i++ ) { if ( results[i].getAttribute("name") === match[1] ) { ret.push( results[i] ); } } return ret.length === 0 ? null : ret; } }, TAG: function( match, context ) { if ( typeof context.getElementsByTagName !== "undefined" ) { return context.getElementsByTagName( match[1] ); } } }, preFilter: { CLASS: function( match, curLoop, inplace, result, not, isXML ) { match = " " + match[1].replace( rBackslash, "" ) + " "; if ( isXML ) { return match; } for ( var i = 0, elem; (elem = curLoop[i]) != null; i++ ) { if ( elem ) { if ( not ^ (elem.className && (" " + elem.className + " ").replace(/[\t\n\r]/g, " ").indexOf(match) >= 0) ) { if ( !inplace ) { result.push( elem ); } } else if ( inplace ) { curLoop[i] = false; } } } return false; }, ID: function( match ) { return match[1].replace( rBackslash, "" ); }, TAG: function( match, curLoop ) { return match[1].replace( rBackslash, "" ).toLowerCase(); }, CHILD: function( match ) { if ( match[1] === "nth" ) { if ( !match[2] ) { Sizzle.error( match[0] ); } match[2] = match[2].replace(/^\+|\s*/g, ''); // parse equations like 'even', 'odd', '5', '2n', '3n+2', '4n-1', '-n+6' var test = /(-?)(\d*)(?:n([+\-]?\d*))?/.exec( match[2] === "even" && "2n" || match[2] === "odd" && "2n+1" || !/\D/.test( match[2] ) && "0n+" + match[2] || match[2]); // calculate the numbers (first)n+(last) including if they are negative match[2] = (test[1] + (test[2] || 1)) - 0; match[3] = test[3] - 0; } else if ( match[2] ) { Sizzle.error( match[0] ); } // TODO: Move to normal caching system match[0] = done++; return match; }, ATTR: function( match, curLoop, inplace, result, not, isXML ) { var name = match[1] = match[1].replace( rBackslash, "" ); if ( !isXML && Expr.attrMap[name] ) { match[1] = Expr.attrMap[name]; } // Handle if an un-quoted value was used match[4] = ( match[4] || match[5] || "" ).replace( rBackslash, "" ); if ( match[2] === "~=" ) { match[4] = " " + match[4] + " "; } return match; }, PSEUDO: function( match, curLoop, inplace, result, not ) { if ( match[1] === "not" ) { // If we're dealing with a complex expression, or a simple one if ( ( chunker.exec(match[3]) || "" ).length > 1 || /^\w/.test(match[3]) ) { match[3] = Sizzle(match[3], null, null, curLoop); } else { var ret = Sizzle.filter(match[3], curLoop, inplace, true ^ not); if ( !inplace ) { result.push.apply( result, ret ); } return false; } } else if ( Expr.match.POS.test( match[0] ) || Expr.match.CHILD.test( match[0] ) ) { return true; } return match; }, POS: function( match ) { match.unshift( true ); return match; } }, filters: { enabled: function( elem ) { return elem.disabled === false && elem.type !== "hidden"; }, disabled: function( elem ) { return elem.disabled === true; }, checked: function( elem ) { return elem.checked === true; }, selected: function( elem ) { // Accessing this property makes selected-by-default // options in Safari work properly if ( elem.parentNode ) { elem.parentNode.selectedIndex; } return elem.selected === true; }, parent: function( elem ) { return !!elem.firstChild; }, empty: function( elem ) { return !elem.firstChild; }, has: function( elem, i, match ) { return !!Sizzle( match[3], elem ).length; }, header: function( elem ) { return (/h\d/i).test( elem.nodeName ); }, text: function( elem ) { var attr = elem.getAttribute( "type" ), type = elem.type; // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) // use getAttribute instead to test this case return elem.nodeName.toLowerCase() === "input" && "text" === type && ( attr === type || attr === null ); }, radio: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "radio" === elem.type; }, checkbox: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "checkbox" === elem.type; }, file: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "file" === elem.type; }, password: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "password" === elem.type; }, submit: function( elem ) { var name = elem.nodeName.toLowerCase(); return (name === "input" || name === "button") && "submit" === elem.type; }, image: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "image" === elem.type; }, reset: function( elem ) { var name = elem.nodeName.toLowerCase(); return (name === "input" || name === "button") && "reset" === elem.type; }, button: function( elem ) { var name = elem.nodeName.toLowerCase(); return name === "input" && "button" === elem.type || name === "button"; }, input: function( elem ) { return (/input|select|textarea|button/i).test( elem.nodeName ); }, focus: function( elem ) { return elem === elem.ownerDocument.activeElement; } }, setFilters: { first: function( elem, i ) { return i === 0; }, last: function( elem, i, match, array ) { return i === array.length - 1; }, even: function( elem, i ) { return i % 2 === 0; }, odd: function( elem, i ) { return i % 2 === 1; }, lt: function( elem, i, match ) { return i < match[3] - 0; }, gt: function( elem, i, match ) { return i > match[3] - 0; }, nth: function( elem, i, match ) { return match[3] - 0 === i; }, eq: function( elem, i, match ) { return match[3] - 0 === i; } }, filter: { PSEUDO: function( elem, match, i, array ) { var name = match[1], filter = Expr.filters[ name ]; if ( filter ) { return filter( elem, i, match, array ); } else if ( name === "contains" ) { return (elem.textContent || elem.innerText || getText([ elem ]) || "").indexOf(match[3]) >= 0; } else if ( name === "not" ) { var not = match[3]; for ( var j = 0, l = not.length; j < l; j++ ) { if ( not[j] === elem ) { return false; } } return true; } else { Sizzle.error( name ); } }, CHILD: function( elem, match ) { var first, last, doneName, parent, cache, count, diff, type = match[1], node = elem; switch ( type ) { case "only": case "first": while ( (node = node.previousSibling) ) { if ( node.nodeType === 1 ) { return false; } } if ( type === "first" ) { return true; } node = elem; case "last": while ( (node = node.nextSibling) ) { if ( node.nodeType === 1 ) { return false; } } return true; case "nth": first = match[2]; last = match[3]; if ( first === 1 && last === 0 ) { return true; } doneName = match[0]; parent = elem.parentNode; if ( parent && (parent[ expando ] !== doneName || !elem.nodeIndex) ) { count = 0; for ( node = parent.firstChild; node; node = node.nextSibling ) { if ( node.nodeType === 1 ) { node.nodeIndex = ++count; } } parent[ expando ] = doneName; } diff = elem.nodeIndex - last; if ( first === 0 ) { return diff === 0; } else { return ( diff % first === 0 && diff / first >= 0 ); } } }, ID: function( elem, match ) { return elem.nodeType === 1 && elem.getAttribute("id") === match; }, TAG: function( elem, match ) { return (match === "*" && elem.nodeType === 1) || !!elem.nodeName && elem.nodeName.toLowerCase() === match; }, CLASS: function( elem, match ) { return (" " + (elem.className || elem.getAttribute("class")) + " ") .indexOf( match ) > -1; }, ATTR: function( elem, match ) { var name = match[1], result = Sizzle.attr ? Sizzle.attr( elem, name ) : Expr.attrHandle[ name ] ? Expr.attrHandle[ name ]( elem ) : elem[ name ] != null ? elem[ name ] : elem.getAttribute( name ), value = result + "", type = match[2], check = match[4]; return result == null ? type === "!=" : !type && Sizzle.attr ? result != null : type === "=" ? value === check : type === "*=" ? value.indexOf(check) >= 0 : type === "~=" ? (" " + value + " ").indexOf(check) >= 0 : !check ? value && result !== false : type === "!=" ? value !== check : type === "^=" ? value.indexOf(check) === 0 : type === "$=" ? value.substr(value.length - check.length) === check : type === "|=" ? value === check || value.substr(0, check.length + 1) === check + "-" : false; }, POS: function( elem, match, i, array ) { var name = match[2], filter = Expr.setFilters[ name ]; if ( filter ) { return filter( elem, i, match, array ); } } } }; var origPOS = Expr.match.POS, fescape = function(all, num){ return "\\" + (num - 0 + 1); }; for ( var type in Expr.match ) { Expr.match[ type ] = new RegExp( Expr.match[ type ].source + (/(?![^\[]*\])(?![^\(]*\))/.source) ); Expr.leftMatch[ type ] = new RegExp( /(^(?:.|\r|\n)*?)/.source + Expr.match[ type ].source.replace(/\\(\d+)/g, fescape) ); } var makeArray = function( array, results ) { array = Array.prototype.slice.call( array, 0 ); if ( results ) { results.push.apply( results, array ); return results; } return array; }; // Perform a simple check to determine if the browser is capable of // converting a NodeList to an array using builtin methods. // Also verifies that the returned array holds DOM nodes // (which is not the case in the Blackberry browser) try { Array.prototype.slice.call( document.documentElement.childNodes, 0 )[0].nodeType; // Provide a fallback method if it does not work } catch( e ) { makeArray = function( array, results ) { var i = 0, ret = results || []; if ( toString.call(array) === "[object Array]" ) { Array.prototype.push.apply( ret, array ); } else { if ( typeof array.length === "number" ) { for ( var l = array.length; i < l; i++ ) { ret.push( array[i] ); } } else { for ( ; array[i]; i++ ) { ret.push( array[i] ); } } } return ret; }; } var sortOrder, siblingCheck; if ( document.documentElement.compareDocumentPosition ) { sortOrder = function( a, b ) { if ( a === b ) { hasDuplicate = true; return 0; } if ( !a.compareDocumentPosition || !b.compareDocumentPosition ) { return a.compareDocumentPosition ? -1 : 1; } return a.compareDocumentPosition(b) & 4 ? -1 : 1; }; } else { sortOrder = function( a, b ) { // The nodes are identical, we can exit early if ( a === b ) { hasDuplicate = true; return 0; // Fallback to using sourceIndex (in IE) if it's available on both nodes } else if ( a.sourceIndex && b.sourceIndex ) { return a.sourceIndex - b.sourceIndex; } var al, bl, ap = [], bp = [], aup = a.parentNode, bup = b.parentNode, cur = aup; // If the nodes are siblings (or identical) we can do a quick check if ( aup === bup ) { return siblingCheck( a, b ); // If no parents were found then the nodes are disconnected } else if ( !aup ) { return -1; } else if ( !bup ) { return 1; } // Otherwise they're somewhere else in the tree so we need // to build up a full list of the parentNodes for comparison while ( cur ) { ap.unshift( cur ); cur = cur.parentNode; } cur = bup; while ( cur ) { bp.unshift( cur ); cur = cur.parentNode; } al = ap.length; bl = bp.length; // Start walking down the tree looking for a discrepancy for ( var i = 0; i < al && i < bl; i++ ) { if ( ap[i] !== bp[i] ) { return siblingCheck( ap[i], bp[i] ); } } // We ended someplace up the tree so do a sibling check return i === al ? siblingCheck( a, bp[i], -1 ) : siblingCheck( ap[i], b, 1 ); }; siblingCheck = function( a, b, ret ) { if ( a === b ) { return ret; } var cur = a.nextSibling; while ( cur ) { if ( cur === b ) { return -1; } cur = cur.nextSibling; } return 1; }; } // Check to see if the browser returns elements by name when // querying by getElementById (and provide a workaround) (function(){ // We're going to inject a fake input element with a specified name var form = document.createElement("div"), id = "script" + (new Date()).getTime(), root = document.documentElement; form.innerHTML = "<a name='" + id + "'/>"; // Inject it into the root element, check its status, and remove it quickly root.insertBefore( form, root.firstChild ); // The workaround has to do additional checks after a getElementById // Which slows things down for other browsers (hence the branching) if ( document.getElementById( id ) ) { Expr.find.ID = function( match, context, isXML ) { if ( typeof context.getElementById !== "undefined" && !isXML ) { var m = context.getElementById(match[1]); return m ? m.id === match[1] || typeof m.getAttributeNode !== "undefined" && m.getAttributeNode("id").nodeValue === match[1] ? [m] : undefined : []; } }; Expr.filter.ID = function( elem, match ) { var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); return elem.nodeType === 1 && node && node.nodeValue === match; }; } root.removeChild( form ); // release memory in IE root = form = null; })(); (function(){ // Check to see if the browser returns only elements // when doing getElementsByTagName("*") // Create a fake element var div = document.createElement("div"); div.appendChild( document.createComment("") ); // Make sure no comments are found if ( div.getElementsByTagName("*").length > 0 ) { Expr.find.TAG = function( match, context ) { var results = context.getElementsByTagName( match[1] ); // Filter out possible comments if ( match[1] === "*" ) { var tmp = []; for ( var i = 0; results[i]; i++ ) { if ( results[i].nodeType === 1 ) { tmp.push( results[i] ); } } results = tmp; } return results; }; } // Check to see if an attribute returns normalized href attributes div.innerHTML = "<a href='#'></a>"; if ( div.firstChild && typeof div.firstChild.getAttribute !== "undefined" && div.firstChild.getAttribute("href") !== "#" ) { Expr.attrHandle.href = function( elem ) { return elem.getAttribute( "href", 2 ); }; } // release memory in IE div = null; })(); if ( document.querySelectorAll ) { (function(){ var oldSizzle = Sizzle, div = document.createElement("div"), id = "__sizzle__"; div.innerHTML = "<p class='TEST'></p>"; // Safari can't handle uppercase or unicode characters when // in quirks mode. if ( div.querySelectorAll && div.querySelectorAll(".TEST").length === 0 ) { return; } Sizzle = function( query, context, extra, seed ) { context = context || document; // Only use querySelectorAll on non-XML documents // (ID selectors don't work in non-HTML documents) if ( !seed && !Sizzle.isXML(context) ) { // See if we find a selector to speed up var match = /^(\w+$)|^\.([\w\-]+$)|^#([\w\-]+$)/.exec( query ); if ( match && (context.nodeType === 1 || context.nodeType === 9) ) { // Speed-up: Sizzle("TAG") if ( match[1] ) { return makeArray( context.getElementsByTagName( query ), extra ); // Speed-up: Sizzle(".CLASS") } else if ( match[2] && Expr.find.CLASS && context.getElementsByClassName ) { return makeArray( context.getElementsByClassName( match[2] ), extra ); } } if ( context.nodeType === 9 ) { // Speed-up: Sizzle("body") // The body element only exists once, optimize finding it if ( query === "body" && context.body ) { return makeArray( [ context.body ], extra ); // Speed-up: Sizzle("#ID") } else if ( match && match[3] ) { var elem = context.getElementById( match[3] ); // Check parentNode to catch when Blackberry 4.6 returns // nodes that are no longer in the document #6963 if ( elem && elem.parentNode ) { // Handle the case where IE and Opera return items // by name instead of ID if ( elem.id === match[3] ) { return makeArray( [ elem ], extra ); } } else { return makeArray( [], extra ); } } try { return makeArray( context.querySelectorAll(query), extra ); } catch(qsaError) {} // qSA works strangely on Element-rooted queries // We can work around this by specifying an extra ID on the root // and working up from there (Thanks to Andrew Dupont for the technique) // IE 8 doesn't work on object elements } else if ( context.nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { var oldContext = context, old = context.getAttribute( "id" ), nid = old || id, hasParent = context.parentNode, relativeHierarchySelector = /^\s*[+~]/.test( query ); if ( !old ) { context.setAttribute( "id", nid ); } else { nid = nid.replace( /'/g, "\\$&" ); } if ( relativeHierarchySelector && hasParent ) { context = context.parentNode; } try { if ( !relativeHierarchySelector || hasParent ) { return makeArray( context.querySelectorAll( "[id='" + nid + "'] " + query ), extra ); } } catch(pseudoError) { } finally { if ( !old ) { oldContext.removeAttribute( "id" ); } } } } return oldSizzle(query, context, extra, seed); }; for ( var prop in oldSizzle ) { Sizzle[ prop ] = oldSizzle[ prop ]; } // release memory in IE div = null; })(); } (function(){ var html = document.documentElement, matches = html.matchesSelector || html.mozMatchesSelector || html.webkitMatchesSelector || html.msMatchesSelector; if ( matches ) { // Check to see if it's possible to do matchesSelector // on a disconnected node (IE 9 fails this) var disconnectedMatch = !matches.call( document.createElement( "div" ), "div" ), pseudoWorks = false; try { // This should fail with an exception // Gecko does not error, returns false instead matches.call( document.documentElement, "[test!='']:sizzle" ); } catch( pseudoError ) { pseudoWorks = true; } Sizzle.matchesSelector = function( node, expr ) { // Make sure that attribute selectors are quoted expr = expr.replace(/\=\s*([^'"\]]*)\s*\]/g, "='$1']"); if ( !Sizzle.isXML( node ) ) { try { if ( pseudoWorks || !Expr.match.PSEUDO.test( expr ) && !/!=/.test( expr ) ) { var ret = matches.call( node, expr ); // IE 9's matchesSelector returns false on disconnected nodes if ( ret || !disconnectedMatch || // As well, disconnected nodes are said to be in a document // fragment in IE 9, so check for that node.document && node.document.nodeType !== 11 ) { return ret; } } } catch(e) {} } return Sizzle(expr, null, null, [node]).length > 0; }; } })(); (function(){ var div = document.createElement("div"); div.innerHTML = "<div class='test e'></div><div class='test'></div>"; // Opera can't find a second classname (in 9.6) // Also, make sure that getElementsByClassName actually exists if ( !div.getElementsByClassName || div.getElementsByClassName("e").length === 0 ) { return; } // Safari caches class attributes, doesn't catch changes (in 3.2) div.lastChild.className = "e"; if ( div.getElementsByClassName("e").length === 1 ) { return; } Expr.order.splice(1, 0, "CLASS"); Expr.find.CLASS = function( match, context, isXML ) { if ( typeof context.getElementsByClassName !== "undefined" && !isXML ) { return context.getElementsByClassName(match[1]); } }; // release memory in IE div = null; })(); function dirNodeCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { for ( var i = 0, l = checkSet.length; i < l; i++ ) { var elem = checkSet[i]; if ( elem ) { var match = false; elem = elem[dir]; while ( elem ) { if ( elem[ expando ] === doneName ) { match = checkSet[elem.sizset]; break; } if ( elem.nodeType === 1 && !isXML ){ elem[ expando ] = doneName; elem.sizset = i; } if ( elem.nodeName.toLowerCase() === cur ) { match = elem; break; } elem = elem[dir]; } checkSet[i] = match; } } } function dirCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { for ( var i = 0, l = checkSet.length; i < l; i++ ) { var elem = checkSet[i]; if ( elem ) { var match = false; elem = elem[dir]; while ( elem ) { if ( elem[ expando ] === doneName ) { match = checkSet[elem.sizset]; break; } if ( elem.nodeType === 1 ) { if ( !isXML ) { elem[ expando ] = doneName; elem.sizset = i; } if ( typeof cur !== "string" ) { if ( elem === cur ) { match = true; break; } } else if ( Sizzle.filter( cur, [elem] ).length > 0 ) { match = elem; break; } } elem = elem[dir]; } checkSet[i] = match; } } } if ( document.documentElement.contains ) { Sizzle.contains = function( a, b ) { return a !== b && (a.contains ? a.contains(b) : true); }; } else if ( document.documentElement.compareDocumentPosition ) { Sizzle.contains = function( a, b ) { return !!(a.compareDocumentPosition(b) & 16); }; } else { Sizzle.contains = function() { return false; }; } Sizzle.isXML = function( elem ) { // documentElement is verified for cases where it doesn't yet exist // (such as loading iframes in IE - #4833) var documentElement = (elem ? elem.ownerDocument || elem : 0).documentElement; return documentElement ? documentElement.nodeName !== "HTML" : false; }; var posProcess = function( selector, context, seed ) { var match, tmpSet = [], later = "", root = context.nodeType ? [context] : context; // Position selectors must be done after the filter // And so must :not(positional) so we move all PSEUDOs to the end while ( (match = Expr.match.PSEUDO.exec( selector )) ) { later += match[0]; selector = selector.replace( Expr.match.PSEUDO, "" ); } selector = Expr.relative[selector] ? selector + "*" : selector; for ( var i = 0, l = root.length; i < l; i++ ) { Sizzle( selector, root[i], tmpSet, seed ); } return Sizzle.filter( later, tmpSet ); }; // EXPOSE // Override sizzle attribute retrieval Sizzle.attr = jQuery.attr; Sizzle.selectors.attrMap = {}; jQuery.find = Sizzle; jQuery.expr = Sizzle.selectors; jQuery.expr[":"] = jQuery.expr.filters; jQuery.unique = Sizzle.uniqueSort; jQuery.text = Sizzle.getText; jQuery.isXMLDoc = Sizzle.isXML; jQuery.contains = Sizzle.contains; })(); var runtil = /Until$/, rparentsprev = /^(?:parents|prevUntil|prevAll)/, // Note: This RegExp should be improved, or likely pulled from Sizzle rmultiselector = /,/, isSimple = /^.[^:#\[\.,]*$/, slice = Array.prototype.slice, POS = jQuery.expr.match.POS, // methods guaranteed to produce a unique set when starting from a unique set guaranteedUnique = { children: true, contents: true, next: true, prev: true }; jQuery.fn.extend({ find: function( selector ) { var self = this, i, l; if ( typeof selector !== "string" ) { return jQuery( selector ).filter(function() { for ( i = 0, l = self.length; i < l; i++ ) { if ( jQuery.contains( self[ i ], this ) ) { return true; } } }); } var ret = this.pushStack( "", "find", selector ), length, n, r; for ( i = 0, l = this.length; i < l; i++ ) { length = ret.length; jQuery.find( selector, this[i], ret ); if ( i > 0 ) { // Make sure that the results are unique for ( n = length; n < ret.length; n++ ) { for ( r = 0; r < length; r++ ) { if ( ret[r] === ret[n] ) { ret.splice(n--, 1); break; } } } } } return ret; }, has: function( target ) { var targets = jQuery( target ); return this.filter(function() { for ( var i = 0, l = targets.length; i < l; i++ ) { if ( jQuery.contains( this, targets[i] ) ) { return true; } } }); }, not: function( selector ) { return this.pushStack( winnow(this, selector, false), "not", selector); }, filter: function( selector ) { return this.pushStack( winnow(this, selector, true), "filter", selector ); }, is: function( selector ) { return !!selector && ( typeof selector === "string" ? // If this is a positional selector, check membership in the returned set // so $("p:first").is("p:last") won't return true for a doc with two "p". POS.test( selector ) ? jQuery( selector, this.context ).index( this[0] ) >= 0 : jQuery.filter( selector, this ).length > 0 : this.filter( selector ).length > 0 ); }, closest: function( selectors, context ) { var ret = [], i, l, cur = this[0]; // Array (deprecated as of jQuery 1.7) if ( jQuery.isArray( selectors ) ) { var level = 1; while ( cur && cur.ownerDocument && cur !== context ) { for ( i = 0; i < selectors.length; i++ ) { if ( jQuery( cur ).is( selectors[ i ] ) ) { ret.push({ selector: selectors[ i ], elem: cur, level: level }); } } cur = cur.parentNode; level++; } return ret; } // String var pos = POS.test( selectors ) || typeof selectors !== "string" ? jQuery( selectors, context || this.context ) : 0; for ( i = 0, l = this.length; i < l; i++ ) { cur = this[i]; while ( cur ) { if ( pos ? pos.index(cur) > -1 : jQuery.find.matchesSelector(cur, selectors) ) { ret.push( cur ); break; } else { cur = cur.parentNode; if ( !cur || !cur.ownerDocument || cur === context || cur.nodeType === 11 ) { break; } } } } ret = ret.length > 1 ? jQuery.unique( ret ) : ret; return this.pushStack( ret, "closest", selectors ); }, // Determine the position of an element within // the matched set of elements index: function( elem ) { // No argument, return index in parent if ( !elem ) { return ( this[0] && this[0].parentNode ) ? this.prevAll().length : -1; } // index in selector if ( typeof elem === "string" ) { return jQuery.inArray( this[0], jQuery( elem ) ); } // Locate the position of the desired element return jQuery.inArray( // If it receives a jQuery object, the first element is used elem.jquery ? elem[0] : elem, this ); }, add: function( selector, context ) { var set = typeof selector === "string" ? jQuery( selector, context ) : jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ), all = jQuery.merge( this.get(), set ); return this.pushStack( isDisconnected( set[0] ) || isDisconnected( all[0] ) ? all : jQuery.unique( all ) ); }, andSelf: function() { return this.add( this.prevObject ); } }); // A painfully simple check to see if an element is disconnected // from a document (should be improved, where feasible). function isDisconnected( node ) { return !node || !node.parentNode || node.parentNode.nodeType === 11; } jQuery.each({ parent: function( elem ) { var parent = elem.parentNode; return parent && parent.nodeType !== 11 ? parent : null; }, parents: function( elem ) { return jQuery.dir( elem, "parentNode" ); }, parentsUntil: function( elem, i, until ) { return jQuery.dir( elem, "parentNode", until ); }, next: function( elem ) { return jQuery.nth( elem, 2, "nextSibling" ); }, prev: function( elem ) { return jQuery.nth( elem, 2, "previousSibling" ); }, nextAll: function( elem ) { return jQuery.dir( elem, "nextSibling" ); }, prevAll: function( elem ) { return jQuery.dir( elem, "previousSibling" ); }, nextUntil: function( elem, i, until ) { return jQuery.dir( elem, "nextSibling", until ); }, prevUntil: function( elem, i, until ) { return jQuery.dir( elem, "previousSibling", until ); }, siblings: function( elem ) { return jQuery.sibling( elem.parentNode.firstChild, elem ); }, children: function( elem ) { return jQuery.sibling( elem.firstChild ); }, contents: function( elem ) { return jQuery.nodeName( elem, "iframe" ) ? elem.contentDocument || elem.contentWindow.document : jQuery.makeArray( elem.childNodes ); } }, function( name, fn ) { jQuery.fn[ name ] = function( until, selector ) { var ret = jQuery.map( this, fn, until ); if ( !runtil.test( name ) ) { selector = until; } if ( selector && typeof selector === "string" ) { ret = jQuery.filter( selector, ret ); } ret = this.length > 1 && !guaranteedUnique[ name ] ? jQuery.unique( ret ) : ret; if ( (this.length > 1 || rmultiselector.test( selector )) && rparentsprev.test( name ) ) { ret = ret.reverse(); } return this.pushStack( ret, name, slice.call( arguments ).join(",") ); }; }); jQuery.extend({ filter: function( expr, elems, not ) { if ( not ) { expr = ":not(" + expr + ")"; } return elems.length === 1 ? jQuery.find.matchesSelector(elems[0], expr) ? [ elems[0] ] : [] : jQuery.find.matches(expr, elems); }, dir: function( elem, dir, until ) { var matched = [], cur = elem[ dir ]; while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { if ( cur.nodeType === 1 ) { matched.push( cur ); } cur = cur[dir]; } return matched; }, nth: function( cur, result, dir, elem ) { result = result || 1; var num = 0; for ( ; cur; cur = cur[dir] ) { if ( cur.nodeType === 1 && ++num === result ) { break; } } return cur; }, sibling: function( n, elem ) { var r = []; for ( ; n; n = n.nextSibling ) { if ( n.nodeType === 1 && n !== elem ) { r.push( n ); } } return r; } }); // Implement the identical functionality for filter and not function winnow( elements, qualifier, keep ) { // Can't pass null or undefined to indexOf in Firefox 4 // Set to 0 to skip string check qualifier = qualifier || 0; if ( jQuery.isFunction( qualifier ) ) { return jQuery.grep(elements, function( elem, i ) { var retVal = !!qualifier.call( elem, i, elem ); return retVal === keep; }); } else if ( qualifier.nodeType ) { return jQuery.grep(elements, function( elem, i ) { return ( elem === qualifier ) === keep; }); } else if ( typeof qualifier === "string" ) { var filtered = jQuery.grep(elements, function( elem ) { return elem.nodeType === 1; }); if ( isSimple.test( qualifier ) ) { return jQuery.filter(qualifier, filtered, !keep); } else { qualifier = jQuery.filter( qualifier, filtered ); } } return jQuery.grep(elements, function( elem, i ) { return ( jQuery.inArray( elem, qualifier ) >= 0 ) === keep; }); } function createSafeFragment( document ) { var list = nodeNames.split( "|" ), safeFrag = document.createDocumentFragment(); if ( safeFrag.createElement ) { while ( list.length ) { safeFrag.createElement( list.pop() ); } } return safeFrag; } var nodeNames = "abbr|article|aside|audio|canvas|datalist|details|figcaption|figure|footer|" + "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", rinlinejQuery = / jQuery\d+="(?:\d+|null)"/g, rleadingWhitespace = /^\s+/, rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig, rtagName = /<([\w:]+)/, rtbody = /<tbody/i, rhtml = /<|&#?\w+;/, rnoInnerhtml = /<(?:script|style)/i, rnocache = /<(?:script|object|embed|option|style)/i, rnoshimcache = new RegExp("<(?:" + nodeNames + ")", "i"), // checked="checked" or checked rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, rscriptType = /\/(java|ecma)script/i, rcleanScript = /^\s*<!(?:\[CDATA\[|\-\-)/, wrapMap = { option: [ 1, "<select multiple='multiple'>", "</select>" ], legend: [ 1, "<fieldset>", "</fieldset>" ], thead: [ 1, "<table>", "</table>" ], tr: [ 2, "<table><tbody>", "</tbody></table>" ], td: [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ], col: [ 2, "<table><tbody></tbody><colgroup>", "</colgroup></table>" ], area: [ 1, "<map>", "</map>" ], _default: [ 0, "", "" ] }, safeFragment = createSafeFragment( document ); wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; // IE can't serialize <link> and <script> tags normally if ( !jQuery.support.htmlSerialize ) { wrapMap._default = [ 1, "div<div>", "</div>" ]; } jQuery.fn.extend({ text: function( text ) { if ( jQuery.isFunction(text) ) { return this.each(function(i) { var self = jQuery( this ); self.text( text.call(this, i, self.text()) ); }); } if ( typeof text !== "object" && text !== undefined ) { return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); } return jQuery.text( this ); }, wrapAll: function( html ) { if ( jQuery.isFunction( html ) ) { return this.each(function(i) { jQuery(this).wrapAll( html.call(this, i) ); }); } if ( this[0] ) { // The elements to wrap the target around var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); if ( this[0].parentNode ) { wrap.insertBefore( this[0] ); } wrap.map(function() { var elem = this; while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { elem = elem.firstChild; } return elem; }).append( this ); } return this; }, wrapInner: function( html ) { if ( jQuery.isFunction( html ) ) { return this.each(function(i) { jQuery(this).wrapInner( html.call(this, i) ); }); } return this.each(function() { var self = jQuery( this ), contents = self.contents(); if ( contents.length ) { contents.wrapAll( html ); } else { self.append( html ); } }); }, wrap: function( html ) { var isFunction = jQuery.isFunction( html ); return this.each(function(i) { jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); }); }, unwrap: function() { return this.parent().each(function() { if ( !jQuery.nodeName( this, "body" ) ) { jQuery( this ).replaceWith( this.childNodes ); } }).end(); }, append: function() { return this.domManip(arguments, true, function( elem ) { if ( this.nodeType === 1 ) { this.appendChild( elem ); } }); }, prepend: function() { return this.domManip(arguments, true, function( elem ) { if ( this.nodeType === 1 ) { this.insertBefore( elem, this.firstChild ); } }); }, before: function() { if ( this[0] && this[0].parentNode ) { return this.domManip(arguments, false, function( elem ) { this.parentNode.insertBefore( elem, this ); }); } else if ( arguments.length ) { var set = jQuery.clean( arguments ); set.push.apply( set, this.toArray() ); return this.pushStack( set, "before", arguments ); } }, after: function() { if ( this[0] && this[0].parentNode ) { return this.domManip(arguments, false, function( elem ) { this.parentNode.insertBefore( elem, this.nextSibling ); }); } else if ( arguments.length ) { var set = this.pushStack( this, "after", arguments ); set.push.apply( set, jQuery.clean(arguments) ); return set; } }, // keepData is for internal use only--do not document remove: function( selector, keepData ) { for ( var i = 0, elem; (elem = this[i]) != null; i++ ) { if ( !selector || jQuery.filter( selector, [ elem ] ).length ) { if ( !keepData && elem.nodeType === 1 ) { jQuery.cleanData( elem.getElementsByTagName("*") ); jQuery.cleanData( [ elem ] ); } if ( elem.parentNode ) { elem.parentNode.removeChild( elem ); } } } return this; }, empty: function() { for ( var i = 0, elem; (elem = this[i]) != null; i++ ) { // Remove element nodes and prevent memory leaks if ( elem.nodeType === 1 ) { jQuery.cleanData( elem.getElementsByTagName("*") ); } // Remove any remaining nodes while ( elem.firstChild ) { elem.removeChild( elem.firstChild ); } } return this; }, clone: function( dataAndEvents, deepDataAndEvents ) { dataAndEvents = dataAndEvents == null ? false : dataAndEvents; deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; return this.map( function () { return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); }); }, html: function( value ) { if ( value === undefined ) { return this[0] && this[0].nodeType === 1 ? this[0].innerHTML.replace(rinlinejQuery, "") : null; // See if we can take a shortcut and just use innerHTML } else if ( typeof value === "string" && !rnoInnerhtml.test( value ) && (jQuery.support.leadingWhitespace || !rleadingWhitespace.test( value )) && !wrapMap[ (rtagName.exec( value ) || ["", ""])[1].toLowerCase() ] ) { value = value.replace(rxhtmlTag, "<$1></$2>"); try { for ( var i = 0, l = this.length; i < l; i++ ) { // Remove element nodes and prevent memory leaks if ( this[i].nodeType === 1 ) { jQuery.cleanData( this[i].getElementsByTagName("*") ); this[i].innerHTML = value; } } // If using innerHTML throws an exception, use the fallback method } catch(e) { this.empty().append( value ); } } else if ( jQuery.isFunction( value ) ) { this.each(function(i){ var self = jQuery( this ); self.html( value.call(this, i, self.html()) ); }); } else { this.empty().append( value ); } return this; }, replaceWith: function( value ) { if ( this[0] && this[0].parentNode ) { // Make sure that the elements are removed from the DOM before they are inserted // this can help fix replacing a parent with child elements if ( jQuery.isFunction( value ) ) { return this.each(function(i) { var self = jQuery(this), old = self.html(); self.replaceWith( value.call( this, i, old ) ); }); } if ( typeof value !== "string" ) { value = jQuery( value ).detach(); } return this.each(function() { var next = this.nextSibling, parent = this.parentNode; jQuery( this ).remove(); if ( next ) { jQuery(next).before( value ); } else { jQuery(parent).append( value ); } }); } else { return this.length ? this.pushStack( jQuery(jQuery.isFunction(value) ? value() : value), "replaceWith", value ) : this; } }, detach: function( selector ) { return this.remove( selector, true ); }, domManip: function( args, table, callback ) { var results, first, fragment, parent, value = args[0], scripts = []; // We can't cloneNode fragments that contain checked, in WebKit if ( !jQuery.support.checkClone && arguments.length === 3 && typeof value === "string" && rchecked.test( value ) ) { return this.each(function() { jQuery(this).domManip( args, table, callback, true ); }); } if ( jQuery.isFunction(value) ) { return this.each(function(i) { var self = jQuery(this); args[0] = value.call(this, i, table ? self.html() : undefined); self.domManip( args, table, callback ); }); } if ( this[0] ) { parent = value && value.parentNode; // If we're in a fragment, just use that instead of building a new one if ( jQuery.support.parentNode && parent && parent.nodeType === 11 && parent.childNodes.length === this.length ) { results = { fragment: parent }; } else { results = jQuery.buildFragment( args, this, scripts ); } fragment = results.fragment; if ( fragment.childNodes.length === 1 ) { first = fragment = fragment.firstChild; } else { first = fragment.firstChild; } if ( first ) { table = table && jQuery.nodeName( first, "tr" ); for ( var i = 0, l = this.length, lastIndex = l - 1; i < l; i++ ) { callback.call( table ? root(this[i], first) : this[i], // Make sure that we do not leak memory by inadvertently discarding // the original fragment (which might have attached data) instead of // using it; in addition, use the original fragment object for the last // item instead of first because it can end up being emptied incorrectly // in certain situations (Bug #8070). // Fragments from the fragment cache must always be cloned and never used // in place. results.cacheable || ( l > 1 && i < lastIndex ) ? jQuery.clone( fragment, true, true ) : fragment ); } } if ( scripts.length ) { jQuery.each( scripts, evalScript ); } } return this; } }); function root( elem, cur ) { return jQuery.nodeName(elem, "table") ? (elem.getElementsByTagName("tbody")[0] || elem.appendChild(elem.ownerDocument.createElement("tbody"))) : elem; } function cloneCopyEvent( src, dest ) { if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { return; } var type, i, l, oldData = jQuery._data( src ), curData = jQuery._data( dest, oldData ), events = oldData.events; if ( events ) { delete curData.handle; curData.events = {}; for ( type in events ) { for ( i = 0, l = events[ type ].length; i < l; i++ ) { jQuery.event.add( dest, type + ( events[ type ][ i ].namespace ? "." : "" ) + events[ type ][ i ].namespace, events[ type ][ i ], events[ type ][ i ].data ); } } } // make the cloned public data object a copy from the original if ( curData.data ) { curData.data = jQuery.extend( {}, curData.data ); } } function cloneFixAttributes( src, dest ) { var nodeName; // We do not need to do anything for non-Elements if ( dest.nodeType !== 1 ) { return; } // clearAttributes removes the attributes, which we don't want, // but also removes the attachEvent events, which we *do* want if ( dest.clearAttributes ) { dest.clearAttributes(); } // mergeAttributes, in contrast, only merges back on the // original attributes, not the events if ( dest.mergeAttributes ) { dest.mergeAttributes( src ); } nodeName = dest.nodeName.toLowerCase(); // IE6-8 fail to clone children inside object elements that use // the proprietary classid attribute value (rather than the type // attribute) to identify the type of content to display if ( nodeName === "object" ) { dest.outerHTML = src.outerHTML; } else if ( nodeName === "input" && (src.type === "checkbox" || src.type === "radio") ) { // IE6-8 fails to persist the checked state of a cloned checkbox // or radio button. Worse, IE6-7 fail to give the cloned element // a checked appearance if the defaultChecked value isn't also set if ( src.checked ) { dest.defaultChecked = dest.checked = src.checked; } // IE6-7 get confused and end up setting the value of a cloned // checkbox/radio button to an empty string instead of "on" if ( dest.value !== src.value ) { dest.value = src.value; } // IE6-8 fails to return the selected option to the default selected // state when cloning options } else if ( nodeName === "option" ) { dest.selected = src.defaultSelected; // IE6-8 fails to set the defaultValue to the correct value when // cloning other types of input fields } else if ( nodeName === "input" || nodeName === "textarea" ) { dest.defaultValue = src.defaultValue; } // Event data gets referenced instead of copied if the expando // gets copied too dest.removeAttribute( jQuery.expando ); } jQuery.buildFragment = function( args, nodes, scripts ) { var fragment, cacheable, cacheresults, doc, first = args[ 0 ]; // nodes may contain either an explicit document object, // a jQuery collection or context object. // If nodes[0] contains a valid object to assign to doc if ( nodes && nodes[0] ) { doc = nodes[0].ownerDocument || nodes[0]; } // Ensure that an attr object doesn't incorrectly stand in as a document object // Chrome and Firefox seem to allow this to occur and will throw exception // Fixes #8950 if ( !doc.createDocumentFragment ) { doc = document; } // Only cache "small" (1/2 KB) HTML strings that are associated with the main document // Cloning options loses the selected state, so don't cache them // IE 6 doesn't like it when you put <object> or <embed> elements in a fragment // Also, WebKit does not clone 'checked' attributes on cloneNode, so don't cache // Lastly, IE6,7,8 will not correctly reuse cached fragments that were created from unknown elems #10501 if ( args.length === 1 && typeof first === "string" && first.length < 512 && doc === document && first.charAt(0) === "<" && !rnocache.test( first ) && (jQuery.support.checkClone || !rchecked.test( first )) && (jQuery.support.html5Clone || !rnoshimcache.test( first )) ) { cacheable = true; cacheresults = jQuery.fragments[ first ]; if ( cacheresults && cacheresults !== 1 ) { fragment = cacheresults; } } if ( !fragment ) { fragment = doc.createDocumentFragment(); jQuery.clean( args, doc, fragment, scripts ); } if ( cacheable ) { jQuery.fragments[ first ] = cacheresults ? fragment : 1; } return { fragment: fragment, cacheable: cacheable }; }; jQuery.fragments = {}; jQuery.each({ appendTo: "append", prependTo: "prepend", insertBefore: "before", insertAfter: "after", replaceAll: "replaceWith" }, function( name, original ) { jQuery.fn[ name ] = function( selector ) { var ret = [], insert = jQuery( selector ), parent = this.length === 1 && this[0].parentNode; if ( parent && parent.nodeType === 11 && parent.childNodes.length === 1 && insert.length === 1 ) { insert[ original ]( this[0] ); return this; } else { for ( var i = 0, l = insert.length; i < l; i++ ) { var elems = ( i > 0 ? this.clone(true) : this ).get(); jQuery( insert[i] )[ original ]( elems ); ret = ret.concat( elems ); } return this.pushStack( ret, name, insert.selector ); } }; }); function getAll( elem ) { if ( typeof elem.getElementsByTagName !== "undefined" ) { return elem.getElementsByTagName( "*" ); } else if ( typeof elem.querySelectorAll !== "undefined" ) { return elem.querySelectorAll( "*" ); } else { return []; } } // Used in clean, fixes the defaultChecked property function fixDefaultChecked( elem ) { if ( elem.type === "checkbox" || elem.type === "radio" ) { elem.defaultChecked = elem.checked; } } // Finds all inputs and passes them to fixDefaultChecked function findInputs( elem ) { var nodeName = ( elem.nodeName || "" ).toLowerCase(); if ( nodeName === "input" ) { fixDefaultChecked( elem ); // Skip scripts, get other children } else if ( nodeName !== "script" && typeof elem.getElementsByTagName !== "undefined" ) { jQuery.grep( elem.getElementsByTagName("input"), fixDefaultChecked ); } } // Derived From: http://www.iecss.com/shimprove/javascript/shimprove.1-0-1.js function shimCloneNode( elem ) { var div = document.createElement( "div" ); safeFragment.appendChild( div ); div.innerHTML = elem.outerHTML; return div.firstChild; } jQuery.extend({ clone: function( elem, dataAndEvents, deepDataAndEvents ) { var srcElements, destElements, i, // IE<=8 does not properly clone detached, unknown element nodes clone = jQuery.support.html5Clone || !rnoshimcache.test( "<" + elem.nodeName ) ? elem.cloneNode( true ) : shimCloneNode( elem ); if ( (!jQuery.support.noCloneEvent || !jQuery.support.noCloneChecked) && (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { // IE copies events bound via attachEvent when using cloneNode. // Calling detachEvent on the clone will also remove the events // from the original. In order to get around this, we use some // proprietary methods to clear the events. Thanks to MooTools // guys for this hotness. cloneFixAttributes( elem, clone ); // Using Sizzle here is crazy slow, so we use getElementsByTagName instead srcElements = getAll( elem ); destElements = getAll( clone ); // Weird iteration because IE will replace the length property // with an element if you are cloning the body and one of the // elements on the page has a name or id of "length" for ( i = 0; srcElements[i]; ++i ) { // Ensure that the destination node is not null; Fixes #9587 if ( destElements[i] ) { cloneFixAttributes( srcElements[i], destElements[i] ); } } } // Copy the events from the original to the clone if ( dataAndEvents ) { cloneCopyEvent( elem, clone ); if ( deepDataAndEvents ) { srcElements = getAll( elem ); destElements = getAll( clone ); for ( i = 0; srcElements[i]; ++i ) { cloneCopyEvent( srcElements[i], destElements[i] ); } } } srcElements = destElements = null; // Return the cloned set return clone; }, clean: function( elems, context, fragment, scripts ) { var checkScriptType; context = context || document; // !context.createElement fails in IE with an error but returns typeof 'object' if ( typeof context.createElement === "undefined" ) { context = context.ownerDocument || context[0] && context[0].ownerDocument || document; } var ret = [], j; for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { if ( typeof elem === "number" ) { elem += ""; } if ( !elem ) { continue; } // Convert html string into DOM nodes if ( typeof elem === "string" ) { if ( !rhtml.test( elem ) ) { elem = context.createTextNode( elem ); } else { // Fix "XHTML"-style tags in all browsers elem = elem.replace(rxhtmlTag, "<$1></$2>"); // Trim whitespace, otherwise indexOf won't work as expected var tag = ( rtagName.exec( elem ) || ["", ""] )[1].toLowerCase(), wrap = wrapMap[ tag ] || wrapMap._default, depth = wrap[0], div = context.createElement("div"); // Append wrapper element to unknown element safe doc fragment if ( context === document ) { // Use the fragment we've already created for this document safeFragment.appendChild( div ); } else { // Use a fragment created with the owner document createSafeFragment( context ).appendChild( div ); } // Go to html and back, then peel off extra wrappers div.innerHTML = wrap[1] + elem + wrap[2]; // Move to the right depth while ( depth-- ) { div = div.lastChild; } // Remove IE's autoinserted <tbody> from table fragments if ( !jQuery.support.tbody ) { // String was a <table>, *may* have spurious <tbody> var hasBody = rtbody.test(elem), tbody = tag === "table" && !hasBody ? div.firstChild && div.firstChild.childNodes : // String was a bare <thead> or <tfoot> wrap[1] === "<table>" && !hasBody ? div.childNodes : []; for ( j = tbody.length - 1; j >= 0 ; --j ) { if ( jQuery.nodeName( tbody[ j ], "tbody" ) && !tbody[ j ].childNodes.length ) { tbody[ j ].parentNode.removeChild( tbody[ j ] ); } } } // IE completely kills leading whitespace when innerHTML is used if ( !jQuery.support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { div.insertBefore( context.createTextNode( rleadingWhitespace.exec(elem)[0] ), div.firstChild ); } elem = div.childNodes; } } // Resets defaultChecked for any radios and checkboxes // about to be appended to the DOM in IE 6/7 (#8060) var len; if ( !jQuery.support.appendChecked ) { if ( elem[0] && typeof (len = elem.length) === "number" ) { for ( j = 0; j < len; j++ ) { findInputs( elem[j] ); } } else { findInputs( elem ); } } if ( elem.nodeType ) { ret.push( elem ); } else { ret = jQuery.merge( ret, elem ); } } if ( fragment ) { checkScriptType = function( elem ) { return !elem.type || rscriptType.test( elem.type ); }; for ( i = 0; ret[i]; i++ ) { if ( scripts && jQuery.nodeName( ret[i], "script" ) && (!ret[i].type || ret[i].type.toLowerCase() === "text/javascript") ) { scripts.push( ret[i].parentNode ? ret[i].parentNode.removeChild( ret[i] ) : ret[i] ); } else { if ( ret[i].nodeType === 1 ) { var jsTags = jQuery.grep( ret[i].getElementsByTagName( "script" ), checkScriptType ); ret.splice.apply( ret, [i + 1, 0].concat( jsTags ) ); } fragment.appendChild( ret[i] ); } } } return ret; }, cleanData: function( elems ) { var data, id, cache = jQuery.cache, special = jQuery.event.special, deleteExpando = jQuery.support.deleteExpando; for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { if ( elem.nodeName && jQuery.noData[elem.nodeName.toLowerCase()] ) { continue; } id = elem[ jQuery.expando ]; if ( id ) { data = cache[ id ]; if ( data && data.events ) { for ( var type in data.events ) { if ( special[ type ] ) { jQuery.event.remove( elem, type ); // This is a shortcut to avoid jQuery.event.remove's overhead } else { jQuery.removeEvent( elem, type, data.handle ); } } // Null the DOM reference to avoid IE6/7/8 leak (#7054) if ( data.handle ) { data.handle.elem = null; } } if ( deleteExpando ) { delete elem[ jQuery.expando ]; } else if ( elem.removeAttribute ) { elem.removeAttribute( jQuery.expando ); } delete cache[ id ]; } } } }); function evalScript( i, elem ) { if ( elem.src ) { jQuery.ajax({ url: elem.src, async: false, dataType: "script" }); } else { jQuery.globalEval( ( elem.text || elem.textContent || elem.innerHTML || "" ).replace( rcleanScript, "/*$0*/" ) ); } if ( elem.parentNode ) { elem.parentNode.removeChild( elem ); } } var ralpha = /alpha\([^)]*\)/i, ropacity = /opacity=([^)]*)/, // fixed for IE9, see #8346 rupper = /([A-Z]|^ms)/g, rnumpx = /^-?\d+(?:px)?$/i, rnum = /^-?\d/, rrelNum = /^([\-+])=([\-+.\de]+)/, cssShow = { position: "absolute", visibility: "hidden", display: "block" }, cssWidth = [ "Left", "Right" ], cssHeight = [ "Top", "Bottom" ], curCSS, getComputedStyle, currentStyle; jQuery.fn.css = function( name, value ) { // Setting 'undefined' is a no-op if ( arguments.length === 2 && value === undefined ) { return this; } return jQuery.access( this, name, value, true, function( elem, name, value ) { return value !== undefined ? jQuery.style( elem, name, value ) : jQuery.css( elem, name ); }); }; jQuery.extend({ // Add in style property hooks for overriding the default // behavior of getting and setting a style property cssHooks: { opacity: { get: function( elem, computed ) { if ( computed ) { // We should always get a number back from opacity var ret = curCSS( elem, "opacity", "opacity" ); return ret === "" ? "1" : ret; } else { return elem.style.opacity; } } } }, // Exclude the following css properties to add px cssNumber: { "fillOpacity": true, "fontWeight": true, "lineHeight": true, "opacity": true, "orphans": true, "widows": true, "zIndex": true, "zoom": true }, // Add in properties whose names you wish to fix before // setting or getting the value cssProps: { // normalize float css property "float": jQuery.support.cssFloat ? "cssFloat" : "styleFloat" }, // Get and set the style property on a DOM Node style: function( elem, name, value, extra ) { // Don't set styles on text and comment nodes if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { return; } // Make sure that we're working with the right name var ret, type, origName = jQuery.camelCase( name ), style = elem.style, hooks = jQuery.cssHooks[ origName ]; name = jQuery.cssProps[ origName ] || origName; // Check if we're setting a value if ( value !== undefined ) { type = typeof value; // convert relative number strings (+= or -=) to relative numbers. #7345 if ( type === "string" && (ret = rrelNum.exec( value )) ) { value = ( +( ret[1] + 1) * +ret[2] ) + parseFloat( jQuery.css( elem, name ) ); // Fixes bug #9237 type = "number"; } // Make sure that NaN and null values aren't set. See: #7116 if ( value == null || type === "number" && isNaN( value ) ) { return; } // If a number was passed in, add 'px' to the (except for certain CSS properties) if ( type === "number" && !jQuery.cssNumber[ origName ] ) { value += "px"; } // If a hook was provided, use that value, otherwise just set the specified value if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value )) !== undefined ) { // Wrapped to prevent IE from throwing errors when 'invalid' values are provided // Fixes bug #5509 try { style[ name ] = value; } catch(e) {} } } else { // If a hook was provided get the non-computed value from there if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) { return ret; } // Otherwise just get the value from the style object return style[ name ]; } }, css: function( elem, name, extra ) { var ret, hooks; // Make sure that we're working with the right name name = jQuery.camelCase( name ); hooks = jQuery.cssHooks[ name ]; name = jQuery.cssProps[ name ] || name; // cssFloat needs a special treatment if ( name === "cssFloat" ) { name = "float"; } // If a hook was provided get the computed value from there if ( hooks && "get" in hooks && (ret = hooks.get( elem, true, extra )) !== undefined ) { return ret; // Otherwise, if a way to get the computed value exists, use that } else if ( curCSS ) { return curCSS( elem, name ); } }, // A method for quickly swapping in/out CSS properties to get correct calculations swap: function( elem, options, callback ) { var old = {}; // Remember the old values, and insert the new ones for ( var name in options ) { old[ name ] = elem.style[ name ]; elem.style[ name ] = options[ name ]; } callback.call( elem ); // Revert the old values for ( name in options ) { elem.style[ name ] = old[ name ]; } } }); // DEPRECATED, Use jQuery.css() instead jQuery.curCSS = jQuery.css; jQuery.each(["height", "width"], function( i, name ) { jQuery.cssHooks[ name ] = { get: function( elem, computed, extra ) { var val; if ( computed ) { if ( elem.offsetWidth !== 0 ) { return getWH( elem, name, extra ); } else { jQuery.swap( elem, cssShow, function() { val = getWH( elem, name, extra ); }); } return val; } }, set: function( elem, value ) { if ( rnumpx.test( value ) ) { // ignore negative width and height values #1599 value = parseFloat( value ); if ( value >= 0 ) { return value + "px"; } } else { return value; } } }; }); if ( !jQuery.support.opacity ) { jQuery.cssHooks.opacity = { get: function( elem, computed ) { // IE uses filters for opacity return ropacity.test( (computed && elem.currentStyle ? elem.currentStyle.filter : elem.style.filter) || "" ) ? ( parseFloat( RegExp.$1 ) / 100 ) + "" : computed ? "1" : ""; }, set: function( elem, value ) { var style = elem.style, currentStyle = elem.currentStyle, opacity = jQuery.isNumeric( value ) ? "alpha(opacity=" + value * 100 + ")" : "", filter = currentStyle && currentStyle.filter || style.filter || ""; // IE has trouble with opacity if it does not have layout // Force it by setting the zoom level style.zoom = 1; // if setting opacity to 1, and no other filters exist - attempt to remove filter attribute #6652 if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) { // Setting style.filter to null, "" & " " still leave "filter:" in the cssText // if "filter:" is present at all, clearType is disabled, we want to avoid this // style.removeAttribute is IE Only, but so apparently is this code path... style.removeAttribute( "filter" ); // if there there is no filter style applied in a css rule, we are done if ( currentStyle && !currentStyle.filter ) { return; } } // otherwise, set new filter values style.filter = ralpha.test( filter ) ? filter.replace( ralpha, opacity ) : filter + " " + opacity; } }; } jQuery(function() { // This hook cannot be added until DOM ready because the support test // for it is not run until after DOM ready if ( !jQuery.support.reliableMarginRight ) { jQuery.cssHooks.marginRight = { get: function( elem, computed ) { // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right // Work around by temporarily setting element display to inline-block var ret; jQuery.swap( elem, { "display": "inline-block" }, function() { if ( computed ) { ret = curCSS( elem, "margin-right", "marginRight" ); } else { ret = elem.style.marginRight; } }); return ret; } }; } }); if ( document.defaultView && document.defaultView.getComputedStyle ) { getComputedStyle = function( elem, name ) { var ret, defaultView, computedStyle; name = name.replace( rupper, "-$1" ).toLowerCase(); if ( (defaultView = elem.ownerDocument.defaultView) && (computedStyle = defaultView.getComputedStyle( elem, null )) ) { ret = computedStyle.getPropertyValue( name ); if ( ret === "" && !jQuery.contains( elem.ownerDocument.documentElement, elem ) ) { ret = jQuery.style( elem, name ); } } return ret; }; } if ( document.documentElement.currentStyle ) { currentStyle = function( elem, name ) { var left, rsLeft, uncomputed, ret = elem.currentStyle && elem.currentStyle[ name ], style = elem.style; // Avoid setting ret to empty string here // so we don't default to auto if ( ret === null && style && (uncomputed = style[ name ]) ) { ret = uncomputed; } // From the awesome hack by Dean Edwards // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 // If we're not dealing with a regular pixel number // but a number that has a weird ending, we need to convert it to pixels if ( !rnumpx.test( ret ) && rnum.test( ret ) ) { // Remember the original values left = style.left; rsLeft = elem.runtimeStyle && elem.runtimeStyle.left; // Put in the new values to get a computed value out if ( rsLeft ) { elem.runtimeStyle.left = elem.currentStyle.left; } style.left = name === "fontSize" ? "1em" : ( ret || 0 ); ret = style.pixelLeft + "px"; // Revert the changed values style.left = left; if ( rsLeft ) { elem.runtimeStyle.left = rsLeft; } } return ret === "" ? "auto" : ret; }; } curCSS = getComputedStyle || currentStyle; function getWH( elem, name, extra ) { // Start with offset property var val = name === "width" ? elem.offsetWidth : elem.offsetHeight, which = name === "width" ? cssWidth : cssHeight, i = 0, len = which.length; if ( val > 0 ) { if ( extra !== "border" ) { for ( ; i < len; i++ ) { if ( !extra ) { val -= parseFloat( jQuery.css( elem, "padding" + which[ i ] ) ) || 0; } if ( extra === "margin" ) { val += parseFloat( jQuery.css( elem, extra + which[ i ] ) ) || 0; } else { val -= parseFloat( jQuery.css( elem, "border" + which[ i ] + "Width" ) ) || 0; } } } return val + "px"; } // Fall back to computed then uncomputed css if necessary val = curCSS( elem, name, name ); if ( val < 0 || val == null ) { val = elem.style[ name ] || 0; } // Normalize "", auto, and prepare for extra val = parseFloat( val ) || 0; // Add padding, border, margin if ( extra ) { for ( ; i < len; i++ ) { val += parseFloat( jQuery.css( elem, "padding" + which[ i ] ) ) || 0; if ( extra !== "padding" ) { val += parseFloat( jQuery.css( elem, "border" + which[ i ] + "Width" ) ) || 0; } if ( extra === "margin" ) { val += parseFloat( jQuery.css( elem, extra + which[ i ] ) ) || 0; } } } return val + "px"; } if ( jQuery.expr && jQuery.expr.filters ) { jQuery.expr.filters.hidden = function( elem ) { var width = elem.offsetWidth, height = elem.offsetHeight; return ( width === 0 && height === 0 ) || (!jQuery.support.reliableHiddenOffsets && ((elem.style && elem.style.display) || jQuery.css( elem, "display" )) === "none"); }; jQuery.expr.filters.visible = function( elem ) { return !jQuery.expr.filters.hidden( elem ); }; } var r20 = /%20/g, rbracket = /\[\]$/, rCRLF = /\r?\n/g, rhash = /#.*$/, rheaders = /^(.*?):[ \t]*([^\r\n]*)\r?$/mg, // IE leaves an \r character at EOL rinput = /^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i, // #7653, #8125, #8152: local protocol detection rlocalProtocol = /^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/, rnoContent = /^(?:GET|HEAD)$/, rprotocol = /^\/\//, rquery = /\?/, rscript = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, rselectTextarea = /^(?:select|textarea)/i, rspacesAjax = /\s+/, rts = /([?&])_=[^&]*/, rurl = /^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+))?)?/, // Keep a copy of the old load method _load = jQuery.fn.load, /* Prefilters * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) * 2) These are called: * - BEFORE asking for a transport * - AFTER param serialization (s.data is a string if s.processData is true) * 3) key is the dataType * 4) the catchall symbol "*" can be used * 5) execution will start with transport dataType and THEN continue down to "*" if needed */ prefilters = {}, /* Transports bindings * 1) key is the dataType * 2) the catchall symbol "*" can be used * 3) selection will start with transport dataType and THEN go to "*" if needed */ transports = {}, // Document location ajaxLocation, // Document location segments ajaxLocParts, // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression allTypes = ["*/"] + ["*"]; // #8138, IE may throw an exception when accessing // a field from window.location if document.domain has been set try { ajaxLocation = location.href; } catch( e ) { // Use the href attribute of an A element // since IE will modify it given document.location ajaxLocation = document.createElement( "a" ); ajaxLocation.href = ""; ajaxLocation = ajaxLocation.href; } // Segment location into parts ajaxLocParts = rurl.exec( ajaxLocation.toLowerCase() ) || []; // Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport function addToPrefiltersOrTransports( structure ) { // dataTypeExpression is optional and defaults to "*" return function( dataTypeExpression, func ) { if ( typeof dataTypeExpression !== "string" ) { func = dataTypeExpression; dataTypeExpression = "*"; } if ( jQuery.isFunction( func ) ) { var dataTypes = dataTypeExpression.toLowerCase().split( rspacesAjax ), i = 0, length = dataTypes.length, dataType, list, placeBefore; // For each dataType in the dataTypeExpression for ( ; i < length; i++ ) { dataType = dataTypes[ i ]; // We control if we're asked to add before // any existing element placeBefore = /^\+/.test( dataType ); if ( placeBefore ) { dataType = dataType.substr( 1 ) || "*"; } list = structure[ dataType ] = structure[ dataType ] || []; // then we add to the structure accordingly list[ placeBefore ? "unshift" : "push" ]( func ); } } }; } // Base inspection function for prefilters and transports function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, dataType /* internal */, inspected /* internal */ ) { dataType = dataType || options.dataTypes[ 0 ]; inspected = inspected || {}; inspected[ dataType ] = true; var list = structure[ dataType ], i = 0, length = list ? list.length : 0, executeOnly = ( structure === prefilters ), selection; for ( ; i < length && ( executeOnly || !selection ); i++ ) { selection = list[ i ]( options, originalOptions, jqXHR ); // If we got redirected to another dataType // we try there if executing only and not done already if ( typeof selection === "string" ) { if ( !executeOnly || inspected[ selection ] ) { selection = undefined; } else { options.dataTypes.unshift( selection ); selection = inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, selection, inspected ); } } } // If we're only executing or nothing was selected // we try the catchall dataType if not done already if ( ( executeOnly || !selection ) && !inspected[ "*" ] ) { selection = inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, "*", inspected ); } // unnecessary when only executing (prefilters) // but it'll be ignored by the caller in that case return selection; } // A special extend for ajax options // that takes "flat" options (not to be deep extended) // Fixes #9887 function ajaxExtend( target, src ) { var key, deep, flatOptions = jQuery.ajaxSettings.flatOptions || {}; for ( key in src ) { if ( src[ key ] !== undefined ) { ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; } } if ( deep ) { jQuery.extend( true, target, deep ); } } jQuery.fn.extend({ load: function( url, params, callback ) { if ( typeof url !== "string" && _load ) { return _load.apply( this, arguments ); // Don't do a request if no elements are being requested } else if ( !this.length ) { return this; } var off = url.indexOf( " " ); if ( off >= 0 ) { var selector = url.slice( off, url.length ); url = url.slice( 0, off ); } // Default to a GET request var type = "GET"; // If the second parameter was provided if ( params ) { // If it's a function if ( jQuery.isFunction( params ) ) { // We assume that it's the callback callback = params; params = undefined; // Otherwise, build a param string } else if ( typeof params === "object" ) { params = jQuery.param( params, jQuery.ajaxSettings.traditional ); type = "POST"; } } var self = this; // Request the remote document jQuery.ajax({ url: url, type: type, dataType: "html", data: params, // Complete callback (responseText is used internally) complete: function( jqXHR, status, responseText ) { // Store the response as specified by the jqXHR object responseText = jqXHR.responseText; // If successful, inject the HTML into all the matched elements if ( jqXHR.isResolved() ) { // #4825: Get the actual response in case // a dataFilter is present in ajaxSettings jqXHR.done(function( r ) { responseText = r; }); // See if a selector was specified self.html( selector ? // Create a dummy div to hold the results jQuery("<div>") // inject the contents of the document in, removing the scripts // to avoid any 'Permission Denied' errors in IE .append(responseText.replace(rscript, "")) // Locate the specified elements .find(selector) : // If not, just inject the full result responseText ); } if ( callback ) { self.each( callback, [ responseText, status, jqXHR ] ); } } }); return this; }, serialize: function() { return jQuery.param( this.serializeArray() ); }, serializeArray: function() { return this.map(function(){ return this.elements ? jQuery.makeArray( this.elements ) : this; }) .filter(function(){ return this.name && !this.disabled && ( this.checked || rselectTextarea.test( this.nodeName ) || rinput.test( this.type ) ); }) .map(function( i, elem ){ var val = jQuery( this ).val(); return val == null ? null : jQuery.isArray( val ) ? jQuery.map( val, function( val, i ){ return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; }) : { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; }).get(); } }); // Attach a bunch of functions for handling common AJAX events jQuery.each( "ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split( " " ), function( i, o ){ jQuery.fn[ o ] = function( f ){ return this.on( o, f ); }; }); jQuery.each( [ "get", "post" ], function( i, method ) { jQuery[ method ] = function( url, data, callback, type ) { // shift arguments if data argument was omitted if ( jQuery.isFunction( data ) ) { type = type || callback; callback = data; data = undefined; } return jQuery.ajax({ type: method, url: url, data: data, success: callback, dataType: type }); }; }); jQuery.extend({ getScript: function( url, callback ) { return jQuery.get( url, undefined, callback, "script" ); }, getJSON: function( url, data, callback ) { return jQuery.get( url, data, callback, "json" ); }, // Creates a full fledged settings object into target // with both ajaxSettings and settings fields. // If target is omitted, writes into ajaxSettings. ajaxSetup: function( target, settings ) { if ( settings ) { // Building a settings object ajaxExtend( target, jQuery.ajaxSettings ); } else { // Extending ajaxSettings settings = target; target = jQuery.ajaxSettings; } ajaxExtend( target, settings ); return target; }, ajaxSettings: { url: ajaxLocation, isLocal: rlocalProtocol.test( ajaxLocParts[ 1 ] ), global: true, type: "GET", contentType: "application/x-www-form-urlencoded", processData: true, async: true, /* timeout: 0, data: null, dataType: null, username: null, password: null, cache: null, traditional: false, headers: {}, */ accepts: { xml: "application/xml, text/xml", html: "text/html", text: "text/plain", json: "application/json, text/javascript", "*": allTypes }, contents: { xml: /xml/, html: /html/, json: /json/ }, responseFields: { xml: "responseXML", text: "responseText" }, // List of data converters // 1) key format is "source_type destination_type" (a single space in-between) // 2) the catchall symbol "*" can be used for source_type converters: { // Convert anything to text "* text": window.String, // Text to html (true = no transformation) "text html": true, // Evaluate text as a json expression "text json": jQuery.parseJSON, // Parse text as xml "text xml": jQuery.parseXML }, // For options that shouldn't be deep extended: // you can add your own custom options here if // and when you create one that shouldn't be // deep extended (see ajaxExtend) flatOptions: { context: true, url: true } }, ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), ajaxTransport: addToPrefiltersOrTransports( transports ), // Main method ajax: function( url, options ) { // If url is an object, simulate pre-1.5 signature if ( typeof url === "object" ) { options = url; url = undefined; } // Force options to be an object options = options || {}; var // Create the final options object s = jQuery.ajaxSetup( {}, options ), // Callbacks context callbackContext = s.context || s, // Context for global events // It's the callbackContext if one was provided in the options // and if it's a DOM node or a jQuery collection globalEventContext = callbackContext !== s && ( callbackContext.nodeType || callbackContext instanceof jQuery ) ? jQuery( callbackContext ) : jQuery.event, // Deferreds deferred = jQuery.Deferred(), completeDeferred = jQuery.Callbacks( "once memory" ), // Status-dependent callbacks statusCode = s.statusCode || {}, // ifModified key ifModifiedKey, // Headers (they are sent all at once) requestHeaders = {}, requestHeadersNames = {}, // Response headers responseHeadersString, responseHeaders, // transport transport, // timeout handle timeoutTimer, // Cross-domain detection vars parts, // The jqXHR state state = 0, // To know if global events are to be dispatched fireGlobals, // Loop variable i, // Fake xhr jqXHR = { readyState: 0, // Caches the header setRequestHeader: function( name, value ) { if ( !state ) { var lname = name.toLowerCase(); name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name; requestHeaders[ name ] = value; } return this; }, // Raw string getAllResponseHeaders: function() { return state === 2 ? responseHeadersString : null; }, // Builds headers hashtable if needed getResponseHeader: function( key ) { var match; if ( state === 2 ) { if ( !responseHeaders ) { responseHeaders = {}; while( ( match = rheaders.exec( responseHeadersString ) ) ) { responseHeaders[ match[1].toLowerCase() ] = match[ 2 ]; } } match = responseHeaders[ key.toLowerCase() ]; } return match === undefined ? null : match; }, // Overrides response content-type header overrideMimeType: function( type ) { if ( !state ) { s.mimeType = type; } return this; }, // Cancel the request abort: function( statusText ) { statusText = statusText || "abort"; if ( transport ) { transport.abort( statusText ); } done( 0, statusText ); return this; } }; // Callback for when everything is done // It is defined here because jslint complains if it is declared // at the end of the function (which would be more logical and readable) function done( status, nativeStatusText, responses, headers ) { // Called once if ( state === 2 ) { return; } // State is "done" now state = 2; // Clear timeout if it exists if ( timeoutTimer ) { clearTimeout( timeoutTimer ); } // Dereference transport for early garbage collection // (no matter how long the jqXHR object will be used) transport = undefined; // Cache response headers responseHeadersString = headers || ""; // Set readyState jqXHR.readyState = status > 0 ? 4 : 0; var isSuccess, success, error, statusText = nativeStatusText, response = responses ? ajaxHandleResponses( s, jqXHR, responses ) : undefined, lastModified, etag; // If successful, handle type chaining if ( status >= 200 && status < 300 || status === 304 ) { // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. if ( s.ifModified ) { if ( ( lastModified = jqXHR.getResponseHeader( "Last-Modified" ) ) ) { jQuery.lastModified[ ifModifiedKey ] = lastModified; } if ( ( etag = jqXHR.getResponseHeader( "Etag" ) ) ) { jQuery.etag[ ifModifiedKey ] = etag; } } // If not modified if ( status === 304 ) { statusText = "notmodified"; isSuccess = true; // If we have data } else { try { success = ajaxConvert( s, response ); statusText = "success"; isSuccess = true; } catch(e) { // We have a parsererror statusText = "parsererror"; error = e; } } } else { // We extract error from statusText // then normalize statusText and status for non-aborts error = statusText; if ( !statusText || status ) { statusText = "error"; if ( status < 0 ) { status = 0; } } } // Set data for the fake xhr object jqXHR.status = status; jqXHR.statusText = "" + ( nativeStatusText || statusText ); // Success/Error if ( isSuccess ) { deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); } else { deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); } // Status-dependent callbacks jqXHR.statusCode( statusCode ); statusCode = undefined; if ( fireGlobals ) { globalEventContext.trigger( "ajax" + ( isSuccess ? "Success" : "Error" ), [ jqXHR, s, isSuccess ? success : error ] ); } // Complete completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); if ( fireGlobals ) { globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); // Handle the global AJAX counter if ( !( --jQuery.active ) ) { jQuery.event.trigger( "ajaxStop" ); } } } // Attach deferreds deferred.promise( jqXHR ); jqXHR.success = jqXHR.done; jqXHR.error = jqXHR.fail; jqXHR.complete = completeDeferred.add; // Status-dependent callbacks jqXHR.statusCode = function( map ) { if ( map ) { var tmp; if ( state < 2 ) { for ( tmp in map ) { statusCode[ tmp ] = [ statusCode[tmp], map[tmp] ]; } } else { tmp = map[ jqXHR.status ]; jqXHR.then( tmp, tmp ); } } return this; }; // Remove hash character (#7531: and string promotion) // Add protocol if not provided (#5866: IE7 issue with protocol-less urls) // We also use the url parameter if available s.url = ( ( url || s.url ) + "" ).replace( rhash, "" ).replace( rprotocol, ajaxLocParts[ 1 ] + "//" ); // Extract dataTypes list s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().split( rspacesAjax ); // Determine if a cross-domain request is in order if ( s.crossDomain == null ) { parts = rurl.exec( s.url.toLowerCase() ); s.crossDomain = !!( parts && ( parts[ 1 ] != ajaxLocParts[ 1 ] || parts[ 2 ] != ajaxLocParts[ 2 ] || ( parts[ 3 ] || ( parts[ 1 ] === "http:" ? 80 : 443 ) ) != ( ajaxLocParts[ 3 ] || ( ajaxLocParts[ 1 ] === "http:" ? 80 : 443 ) ) ) ); } // Convert data if not already a string if ( s.data && s.processData && typeof s.data !== "string" ) { s.data = jQuery.param( s.data, s.traditional ); } // Apply prefilters inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); // If request was aborted inside a prefiler, stop there if ( state === 2 ) { return false; } // We can fire global events as of now if asked to fireGlobals = s.global; // Uppercase the type s.type = s.type.toUpperCase(); // Determine if request has content s.hasContent = !rnoContent.test( s.type ); // Watch for a new set of requests if ( fireGlobals && jQuery.active++ === 0 ) { jQuery.event.trigger( "ajaxStart" ); } // More options handling for requests with no content if ( !s.hasContent ) { // If data is available, append data to url if ( s.data ) { s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.data; // #9682: remove data so that it's not used in an eventual retry delete s.data; } // Get ifModifiedKey before adding the anti-cache parameter ifModifiedKey = s.url; // Add anti-cache in url if needed if ( s.cache === false ) { var ts = jQuery.now(), // try replacing _= if it is there ret = s.url.replace( rts, "$1_=" + ts ); // if nothing was replaced, add timestamp to the end s.url = ret + ( ( ret === s.url ) ? ( rquery.test( s.url ) ? "&" : "?" ) + "_=" + ts : "" ); } } // Set the correct header, if data is being sent if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { jqXHR.setRequestHeader( "Content-Type", s.contentType ); } // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. if ( s.ifModified ) { ifModifiedKey = ifModifiedKey || s.url; if ( jQuery.lastModified[ ifModifiedKey ] ) { jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ ifModifiedKey ] ); } if ( jQuery.etag[ ifModifiedKey ] ) { jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ ifModifiedKey ] ); } } // Set the Accepts header for the server, depending on the dataType jqXHR.setRequestHeader( "Accept", s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[0] ] ? s.accepts[ s.dataTypes[0] ] + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : s.accepts[ "*" ] ); // Check for headers option for ( i in s.headers ) { jqXHR.setRequestHeader( i, s.headers[ i ] ); } // Allow custom headers/mimetypes and early abort if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) { // Abort if not done already jqXHR.abort(); return false; } // Install callbacks on deferreds for ( i in { success: 1, error: 1, complete: 1 } ) { jqXHR[ i ]( s[ i ] ); } // Get transport transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); // If no transport, we auto-abort if ( !transport ) { done( -1, "No Transport" ); } else { jqXHR.readyState = 1; // Send global event if ( fireGlobals ) { globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); } // Timeout if ( s.async && s.timeout > 0 ) { timeoutTimer = setTimeout( function(){ jqXHR.abort( "timeout" ); }, s.timeout ); } try { state = 1; transport.send( requestHeaders, done ); } catch (e) { // Propagate exception as error if not done if ( state < 2 ) { done( -1, e ); // Simply rethrow otherwise } else { throw e; } } } return jqXHR; }, // Serialize an array of form elements or a set of // key/values into a query string param: function( a, traditional ) { var s = [], add = function( key, value ) { // If value is a function, invoke it and return its value value = jQuery.isFunction( value ) ? value() : value; s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value ); }; // Set traditional to true for jQuery <= 1.3.2 behavior. if ( traditional === undefined ) { traditional = jQuery.ajaxSettings.traditional; } // If an array was passed in, assume that it is an array of form elements. if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { // Serialize the form elements jQuery.each( a, function() { add( this.name, this.value ); }); } else { // If traditional, encode the "old" way (the way 1.3.2 or older // did it), otherwise encode params recursively. for ( var prefix in a ) { buildParams( prefix, a[ prefix ], traditional, add ); } } // Return the resulting serialization return s.join( "&" ).replace( r20, "+" ); } }); function buildParams( prefix, obj, traditional, add ) { if ( jQuery.isArray( obj ) ) { // Serialize array item. jQuery.each( obj, function( i, v ) { if ( traditional || rbracket.test( prefix ) ) { // Treat each array item as a scalar. add( prefix, v ); } else { // If array item is non-scalar (array or object), encode its // numeric index to resolve deserialization ambiguity issues. // Note that rack (as of 1.0.0) can't currently deserialize // nested arrays properly, and attempting to do so may cause // a server error. Possible fixes are to modify rack's // deserialization algorithm or to provide an option or flag // to force array serialization to be shallow. buildParams( prefix + "[" + ( typeof v === "object" || jQuery.isArray(v) ? i : "" ) + "]", v, traditional, add ); } }); } else if ( !traditional && obj != null && typeof obj === "object" ) { // Serialize object item. for ( var name in obj ) { buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); } } else { // Serialize scalar item. add( prefix, obj ); } } // This is still on the jQuery object... for now // Want to move this to jQuery.ajax some day jQuery.extend({ // Counter for holding the number of active queries active: 0, // Last-Modified header cache for next request lastModified: {}, etag: {} }); /* Handles responses to an ajax request: * - sets all responseXXX fields accordingly * - finds the right dataType (mediates between content-type and expected dataType) * - returns the corresponding response */ function ajaxHandleResponses( s, jqXHR, responses ) { var contents = s.contents, dataTypes = s.dataTypes, responseFields = s.responseFields, ct, type, finalDataType, firstDataType; // Fill responseXXX fields for ( type in responseFields ) { if ( type in responses ) { jqXHR[ responseFields[type] ] = responses[ type ]; } } // Remove auto dataType and get content-type in the process while( dataTypes[ 0 ] === "*" ) { dataTypes.shift(); if ( ct === undefined ) { ct = s.mimeType || jqXHR.getResponseHeader( "content-type" ); } } // Check if we're dealing with a known content-type if ( ct ) { for ( type in contents ) { if ( contents[ type ] && contents[ type ].test( ct ) ) { dataTypes.unshift( type ); break; } } } // Check to see if we have a response for the expected dataType if ( dataTypes[ 0 ] in responses ) { finalDataType = dataTypes[ 0 ]; } else { // Try convertible dataTypes for ( type in responses ) { if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[0] ] ) { finalDataType = type; break; } if ( !firstDataType ) { firstDataType = type; } } // Or just use first one finalDataType = finalDataType || firstDataType; } // If we found a dataType // We add the dataType to the list if needed // and return the corresponding response if ( finalDataType ) { if ( finalDataType !== dataTypes[ 0 ] ) { dataTypes.unshift( finalDataType ); } return responses[ finalDataType ]; } } // Chain conversions given the request and the original response function ajaxConvert( s, response ) { // Apply the dataFilter if provided if ( s.dataFilter ) { response = s.dataFilter( response, s.dataType ); } var dataTypes = s.dataTypes, converters = {}, i, key, length = dataTypes.length, tmp, // Current and previous dataTypes current = dataTypes[ 0 ], prev, // Conversion expression conversion, // Conversion function conv, // Conversion functions (transitive conversion) conv1, conv2; // For each dataType in the chain for ( i = 1; i < length; i++ ) { // Create converters map // with lowercased keys if ( i === 1 ) { for ( key in s.converters ) { if ( typeof key === "string" ) { converters[ key.toLowerCase() ] = s.converters[ key ]; } } } // Get the dataTypes prev = current; current = dataTypes[ i ]; // If current is auto dataType, update it to prev if ( current === "*" ) { current = prev; // If no auto and dataTypes are actually different } else if ( prev !== "*" && prev !== current ) { // Get the converter conversion = prev + " " + current; conv = converters[ conversion ] || converters[ "* " + current ]; // If there is no direct converter, search transitively if ( !conv ) { conv2 = undefined; for ( conv1 in converters ) { tmp = conv1.split( " " ); if ( tmp[ 0 ] === prev || tmp[ 0 ] === "*" ) { conv2 = converters[ tmp[1] + " " + current ]; if ( conv2 ) { conv1 = converters[ conv1 ]; if ( conv1 === true ) { conv = conv2; } else if ( conv2 === true ) { conv = conv1; } break; } } } } // If we found no converter, dispatch an error if ( !( conv || conv2 ) ) { jQuery.error( "No conversion from " + conversion.replace(" "," to ") ); } // If found converter is not an equivalence if ( conv !== true ) { // Convert with 1 or 2 converters accordingly response = conv ? conv( response ) : conv2( conv1(response) ); } } } return response; } var jsc = jQuery.now(), jsre = /(\=)\?(&|$)|\?\?/i; // Default jsonp settings jQuery.ajaxSetup({ jsonp: "callback", jsonpCallback: function() { return jQuery.expando + "_" + ( jsc++ ); } }); // Detect, normalize options and install callbacks for jsonp requests jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) { var inspectData = s.contentType === "application/x-www-form-urlencoded" && ( typeof s.data === "string" ); if ( s.dataTypes[ 0 ] === "jsonp" || s.jsonp !== false && ( jsre.test( s.url ) || inspectData && jsre.test( s.data ) ) ) { var responseContainer, jsonpCallback = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ? s.jsonpCallback() : s.jsonpCallback, previous = window[ jsonpCallback ], url = s.url, data = s.data, replace = "$1" + jsonpCallback + "$2"; if ( s.jsonp !== false ) { url = url.replace( jsre, replace ); if ( s.url === url ) { if ( inspectData ) { data = data.replace( jsre, replace ); } if ( s.data === data ) { // Add callback manually url += (/\?/.test( url ) ? "&" : "?") + s.jsonp + "=" + jsonpCallback; } } } s.url = url; s.data = data; // Install callback window[ jsonpCallback ] = function( response ) { responseContainer = [ response ]; }; // Clean-up function jqXHR.always(function() { // Set callback back to previous value window[ jsonpCallback ] = previous; // Call if it was a function and we have a response if ( responseContainer && jQuery.isFunction( previous ) ) { window[ jsonpCallback ]( responseContainer[ 0 ] ); } }); // Use data converter to retrieve json after script execution s.converters["script json"] = function() { if ( !responseContainer ) { jQuery.error( jsonpCallback + " was not called" ); } return responseContainer[ 0 ]; }; // force json dataType s.dataTypes[ 0 ] = "json"; // Delegate to script return "script"; } }); // Install script dataType jQuery.ajaxSetup({ accepts: { script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" }, contents: { script: /javascript|ecmascript/ }, converters: { "text script": function( text ) { jQuery.globalEval( text ); return text; } } }); // Handle cache's special case and global jQuery.ajaxPrefilter( "script", function( s ) { if ( s.cache === undefined ) { s.cache = false; } if ( s.crossDomain ) { s.type = "GET"; s.global = false; } }); // Bind script tag hack transport jQuery.ajaxTransport( "script", function(s) { // This transport only deals with cross domain requests if ( s.crossDomain ) { var script, head = document.head || document.getElementsByTagName( "head" )[0] || document.documentElement; return { send: function( _, callback ) { script = document.createElement( "script" ); script.async = "async"; if ( s.scriptCharset ) { script.charset = s.scriptCharset; } script.src = s.url; // Attach handlers for all browsers script.onload = script.onreadystatechange = function( _, isAbort ) { if ( isAbort || !script.readyState || /loaded|complete/.test( script.readyState ) ) { // Handle memory leak in IE script.onload = script.onreadystatechange = null; // Remove the script if ( head && script.parentNode ) { head.removeChild( script ); } // Dereference the script script = undefined; // Callback if not abort if ( !isAbort ) { callback( 200, "success" ); } } }; // Use insertBefore instead of appendChild to circumvent an IE6 bug. // This arises when a base node is used (#2709 and #4378). head.insertBefore( script, head.firstChild ); }, abort: function() { if ( script ) { script.onload( 0, 1 ); } } }; } }); var // #5280: Internet Explorer will keep connections alive if we don't abort on unload xhrOnUnloadAbort = window.ActiveXObject ? function() { // Abort all pending requests for ( var key in xhrCallbacks ) { xhrCallbacks[ key ]( 0, 1 ); } } : false, xhrId = 0, xhrCallbacks; // Functions to create xhrs function createStandardXHR() { try { return new window.XMLHttpRequest(); } catch( e ) {} } function createActiveXHR() { try { return new window.ActiveXObject( "Microsoft.XMLHTTP" ); } catch( e ) {} } // Create the request object // (This is still attached to ajaxSettings for backward compatibility) jQuery.ajaxSettings.xhr = window.ActiveXObject ? /* Microsoft failed to properly * implement the XMLHttpRequest in IE7 (can't request local files), * so we use the ActiveXObject when it is available * Additionally XMLHttpRequest can be disabled in IE7/IE8 so * we need a fallback. */ function() { return !this.isLocal && createStandardXHR() || createActiveXHR(); } : // For all other browsers, use the standard XMLHttpRequest object createStandardXHR; // Determine support properties (function( xhr ) { jQuery.extend( jQuery.support, { ajax: !!xhr, cors: !!xhr && ( "withCredentials" in xhr ) }); })( jQuery.ajaxSettings.xhr() ); // Create transport if the browser can provide an xhr if ( jQuery.support.ajax ) { jQuery.ajaxTransport(function( s ) { // Cross domain only allowed if supported through XMLHttpRequest if ( !s.crossDomain || jQuery.support.cors ) { var callback; return { send: function( headers, complete ) { // Get a new xhr var xhr = s.xhr(), handle, i; // Open the socket // Passing null username, generates a login popup on Opera (#2865) if ( s.username ) { xhr.open( s.type, s.url, s.async, s.username, s.password ); } else { xhr.open( s.type, s.url, s.async ); } // Apply custom fields if provided if ( s.xhrFields ) { for ( i in s.xhrFields ) { xhr[ i ] = s.xhrFields[ i ]; } } // Override mime type if needed if ( s.mimeType && xhr.overrideMimeType ) { xhr.overrideMimeType( s.mimeType ); } // X-Requested-With header // For cross-domain requests, seeing as conditions for a preflight are // akin to a jigsaw puzzle, we simply never set it to be sure. // (it can always be set on a per-request basis or even using ajaxSetup) // For same-domain requests, won't change header if already provided. if ( !s.crossDomain && !headers["X-Requested-With"] ) { headers[ "X-Requested-With" ] = "XMLHttpRequest"; } // Need an extra try/catch for cross domain requests in Firefox 3 try { for ( i in headers ) { xhr.setRequestHeader( i, headers[ i ] ); } } catch( _ ) {} // Do send the request // This may raise an exception which is actually // handled in jQuery.ajax (so no try/catch here) xhr.send( ( s.hasContent && s.data ) || null ); // Listener callback = function( _, isAbort ) { var status, statusText, responseHeaders, responses, xml; // Firefox throws exceptions when accessing properties // of an xhr when a network error occurred // http://helpful.knobs-dials.com/index.php/Component_returned_failure_code:_0x80040111_(NS_ERROR_NOT_AVAILABLE) try { // Was never called and is aborted or complete if ( callback && ( isAbort || xhr.readyState === 4 ) ) { // Only called once callback = undefined; // Do not keep as active anymore if ( handle ) { xhr.onreadystatechange = jQuery.noop; if ( xhrOnUnloadAbort ) { delete xhrCallbacks[ handle ]; } } // If it's an abort if ( isAbort ) { // Abort it manually if needed if ( xhr.readyState !== 4 ) { xhr.abort(); } } else { status = xhr.status; responseHeaders = xhr.getAllResponseHeaders(); responses = {}; xml = xhr.responseXML; // Construct response list if ( xml && xml.documentElement /* #4958 */ ) { responses.xml = xml; } responses.text = xhr.responseText; // Firefox throws an exception when accessing // statusText for faulty cross-domain requests try { statusText = xhr.statusText; } catch( e ) { // We normalize with Webkit giving an empty statusText statusText = ""; } // Filter status for non standard behaviors // If the request is local and we have data: assume a success // (success with no data won't get notified, that's the best we // can do given current implementations) if ( !status && s.isLocal && !s.crossDomain ) { status = responses.text ? 200 : 404; // IE - #1450: sometimes returns 1223 when it should be 204 } else if ( status === 1223 ) { status = 204; } } } } catch( firefoxAccessException ) { if ( !isAbort ) { complete( -1, firefoxAccessException ); } } // Call complete if needed if ( responses ) { complete( status, statusText, responses, responseHeaders ); } }; // if we're in sync mode or it's in cache // and has been retrieved directly (IE6 & IE7) // we need to manually fire the callback if ( !s.async || xhr.readyState === 4 ) { callback(); } else { handle = ++xhrId; if ( xhrOnUnloadAbort ) { // Create the active xhrs callbacks list if needed // and attach the unload handler if ( !xhrCallbacks ) { xhrCallbacks = {}; jQuery( window ).unload( xhrOnUnloadAbort ); } // Add to list of active xhrs callbacks xhrCallbacks[ handle ] = callback; } xhr.onreadystatechange = callback; } }, abort: function() { if ( callback ) { callback(0,1); } } }; } }); } var elemdisplay = {}, iframe, iframeDoc, rfxtypes = /^(?:toggle|show|hide)$/, rfxnum = /^([+\-]=)?([\d+.\-]+)([a-z%]*)$/i, timerId, fxAttrs = [ // height animations [ "height", "marginTop", "marginBottom", "paddingTop", "paddingBottom" ], // width animations [ "width", "marginLeft", "marginRight", "paddingLeft", "paddingRight" ], // opacity animations [ "opacity" ] ], fxNow; jQuery.fn.extend({ show: function( speed, easing, callback ) { var elem, display; if ( speed || speed === 0 ) { return this.animate( genFx("show", 3), speed, easing, callback ); } else { for ( var i = 0, j = this.length; i < j; i++ ) { elem = this[ i ]; if ( elem.style ) { display = elem.style.display; // Reset the inline display of this element to learn if it is // being hidden by cascaded rules or not if ( !jQuery._data(elem, "olddisplay") && display === "none" ) { display = elem.style.display = ""; } // Set elements which have been overridden with display: none // in a stylesheet to whatever the default browser style is // for such an element if ( display === "" && jQuery.css(elem, "display") === "none" ) { jQuery._data( elem, "olddisplay", defaultDisplay(elem.nodeName) ); } } } // Set the display of most of the elements in a second loop // to avoid the constant reflow for ( i = 0; i < j; i++ ) { elem = this[ i ]; if ( elem.style ) { display = elem.style.display; if ( display === "" || display === "none" ) { elem.style.display = jQuery._data( elem, "olddisplay" ) || ""; } } } return this; } }, hide: function( speed, easing, callback ) { if ( speed || speed === 0 ) { return this.animate( genFx("hide", 3), speed, easing, callback); } else { var elem, display, i = 0, j = this.length; for ( ; i < j; i++ ) { elem = this[i]; if ( elem.style ) { display = jQuery.css( elem, "display" ); if ( display !== "none" && !jQuery._data( elem, "olddisplay" ) ) { jQuery._data( elem, "olddisplay", display ); } } } // Set the display of the elements in a second loop // to avoid the constant reflow for ( i = 0; i < j; i++ ) { if ( this[i].style ) { this[i].style.display = "none"; } } return this; } }, // Save the old toggle function _toggle: jQuery.fn.toggle, toggle: function( fn, fn2, callback ) { var bool = typeof fn === "boolean"; if ( jQuery.isFunction(fn) && jQuery.isFunction(fn2) ) { this._toggle.apply( this, arguments ); } else if ( fn == null || bool ) { this.each(function() { var state = bool ? fn : jQuery(this).is(":hidden"); jQuery(this)[ state ? "show" : "hide" ](); }); } else { this.animate(genFx("toggle", 3), fn, fn2, callback); } return this; }, fadeTo: function( speed, to, easing, callback ) { return this.filter(":hidden").css("opacity", 0).show().end() .animate({opacity: to}, speed, easing, callback); }, animate: function( prop, speed, easing, callback ) { var optall = jQuery.speed( speed, easing, callback ); if ( jQuery.isEmptyObject( prop ) ) { return this.each( optall.complete, [ false ] ); } // Do not change referenced properties as per-property easing will be lost prop = jQuery.extend( {}, prop ); function doAnimation() { // XXX 'this' does not always have a nodeName when running the // test suite if ( optall.queue === false ) { jQuery._mark( this ); } var opt = jQuery.extend( {}, optall ), isElement = this.nodeType === 1, hidden = isElement && jQuery(this).is(":hidden"), name, val, p, e, parts, start, end, unit, method; // will store per property easing and be used to determine when an animation is complete opt.animatedProperties = {}; for ( p in prop ) { // property name normalization name = jQuery.camelCase( p ); if ( p !== name ) { prop[ name ] = prop[ p ]; delete prop[ p ]; } val = prop[ name ]; // easing resolution: per property > opt.specialEasing > opt.easing > 'swing' (default) if ( jQuery.isArray( val ) ) { opt.animatedProperties[ name ] = val[ 1 ]; val = prop[ name ] = val[ 0 ]; } else { opt.animatedProperties[ name ] = opt.specialEasing && opt.specialEasing[ name ] || opt.easing || 'swing'; } if ( val === "hide" && hidden || val === "show" && !hidden ) { return opt.complete.call( this ); } if ( isElement && ( name === "height" || name === "width" ) ) { // Make sure that nothing sneaks out // Record all 3 overflow attributes because IE does not // change the overflow attribute when overflowX and // overflowY are set to the same value opt.overflow = [ this.style.overflow, this.style.overflowX, this.style.overflowY ]; // Set display property to inline-block for height/width // animations on inline elements that are having width/height animated if ( jQuery.css( this, "display" ) === "inline" && jQuery.css( this, "float" ) === "none" ) { // inline-level elements accept inline-block; // block-level elements need to be inline with layout if ( !jQuery.support.inlineBlockNeedsLayout || defaultDisplay( this.nodeName ) === "inline" ) { this.style.display = "inline-block"; } else { this.style.zoom = 1; } } } } if ( opt.overflow != null ) { this.style.overflow = "hidden"; } for ( p in prop ) { e = new jQuery.fx( this, opt, p ); val = prop[ p ]; if ( rfxtypes.test( val ) ) { // Tracks whether to show or hide based on private // data attached to the element method = jQuery._data( this, "toggle" + p ) || ( val === "toggle" ? hidden ? "show" : "hide" : 0 ); if ( method ) { jQuery._data( this, "toggle" + p, method === "show" ? "hide" : "show" ); e[ method ](); } else { e[ val ](); } } else { parts = rfxnum.exec( val ); start = e.cur(); if ( parts ) { end = parseFloat( parts[2] ); unit = parts[3] || ( jQuery.cssNumber[ p ] ? "" : "px" ); // We need to compute starting value if ( unit !== "px" ) { jQuery.style( this, p, (end || 1) + unit); start = ( (end || 1) / e.cur() ) * start; jQuery.style( this, p, start + unit); } // If a +=/-= token was provided, we're doing a relative animation if ( parts[1] ) { end = ( (parts[ 1 ] === "-=" ? -1 : 1) * end ) + start; } e.custom( start, end, unit ); } else { e.custom( start, val, "" ); } } } // For JS strict compliance return true; } return optall.queue === false ? this.each( doAnimation ) : this.queue( optall.queue, doAnimation ); }, stop: function( type, clearQueue, gotoEnd ) { if ( typeof type !== "string" ) { gotoEnd = clearQueue; clearQueue = type; type = undefined; } if ( clearQueue && type !== false ) { this.queue( type || "fx", [] ); } return this.each(function() { var index, hadTimers = false, timers = jQuery.timers, data = jQuery._data( this ); // clear marker counters if we know they won't be if ( !gotoEnd ) { jQuery._unmark( true, this ); } function stopQueue( elem, data, index ) { var hooks = data[ index ]; jQuery.removeData( elem, index, true ); hooks.stop( gotoEnd ); } if ( type == null ) { for ( index in data ) { if ( data[ index ] && data[ index ].stop && index.indexOf(".run") === index.length - 4 ) { stopQueue( this, data, index ); } } } else if ( data[ index = type + ".run" ] && data[ index ].stop ){ stopQueue( this, data, index ); } for ( index = timers.length; index--; ) { if ( timers[ index ].elem === this && (type == null || timers[ index ].queue === type) ) { if ( gotoEnd ) { // force the next step to be the last timers[ index ]( true ); } else { timers[ index ].saveState(); } hadTimers = true; timers.splice( index, 1 ); } } // start the next in the queue if the last step wasn't forced // timers currently will call their complete callbacks, which will dequeue // but only if they were gotoEnd if ( !( gotoEnd && hadTimers ) ) { jQuery.dequeue( this, type ); } }); } }); // Animations created synchronously will run synchronously function createFxNow() { setTimeout( clearFxNow, 0 ); return ( fxNow = jQuery.now() ); } function clearFxNow() { fxNow = undefined; } // Generate parameters to create a standard animation function genFx( type, num ) { var obj = {}; jQuery.each( fxAttrs.concat.apply([], fxAttrs.slice( 0, num )), function() { obj[ this ] = type; }); return obj; } // Generate shortcuts for custom animations jQuery.each({ slideDown: genFx( "show", 1 ), slideUp: genFx( "hide", 1 ), slideToggle: genFx( "toggle", 1 ), fadeIn: { opacity: "show" }, fadeOut: { opacity: "hide" }, fadeToggle: { opacity: "toggle" } }, function( name, props ) { jQuery.fn[ name ] = function( speed, easing, callback ) { return this.animate( props, speed, easing, callback ); }; }); jQuery.extend({ speed: function( speed, easing, fn ) { var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { complete: fn || !fn && easing || jQuery.isFunction( speed ) && speed, duration: speed, easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing }; opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration : opt.duration in jQuery.fx.speeds ? jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; // normalize opt.queue - true/undefined/null -> "fx" if ( opt.queue == null || opt.queue === true ) { opt.queue = "fx"; } // Queueing opt.old = opt.complete; opt.complete = function( noUnmark ) { if ( jQuery.isFunction( opt.old ) ) { opt.old.call( this ); } if ( opt.queue ) { jQuery.dequeue( this, opt.queue ); } else if ( noUnmark !== false ) { jQuery._unmark( this ); } }; return opt; }, easing: { linear: function( p, n, firstNum, diff ) { return firstNum + diff * p; }, swing: function( p, n, firstNum, diff ) { return ( ( -Math.cos( p*Math.PI ) / 2 ) + 0.5 ) * diff + firstNum; } }, timers: [], fx: function( elem, options, prop ) { this.options = options; this.elem = elem; this.prop = prop; options.orig = options.orig || {}; } }); jQuery.fx.prototype = { // Simple function for setting a style value update: function() { if ( this.options.step ) { this.options.step.call( this.elem, this.now, this ); } ( jQuery.fx.step[ this.prop ] || jQuery.fx.step._default )( this ); }, // Get the current size cur: function() { if ( this.elem[ this.prop ] != null && (!this.elem.style || this.elem.style[ this.prop ] == null) ) { return this.elem[ this.prop ]; } var parsed, r = jQuery.css( this.elem, this.prop ); // Empty strings, null, undefined and "auto" are converted to 0, // complex values such as "rotate(1rad)" are returned as is, // simple values such as "10px" are parsed to Float. return isNaN( parsed = parseFloat( r ) ) ? !r || r === "auto" ? 0 : r : parsed; }, // Start an animation from one number to another custom: function( from, to, unit ) { var self = this, fx = jQuery.fx; this.startTime = fxNow || createFxNow(); this.end = to; this.now = this.start = from; this.pos = this.state = 0; this.unit = unit || this.unit || ( jQuery.cssNumber[ this.prop ] ? "" : "px" ); function t( gotoEnd ) { return self.step( gotoEnd ); } t.queue = this.options.queue; t.elem = this.elem; t.saveState = function() { if ( self.options.hide && jQuery._data( self.elem, "fxshow" + self.prop ) === undefined ) { jQuery._data( self.elem, "fxshow" + self.prop, self.start ); } }; if ( t() && jQuery.timers.push(t) && !timerId ) { timerId = setInterval( fx.tick, fx.interval ); } }, // Simple 'show' function show: function() { var dataShow = jQuery._data( this.elem, "fxshow" + this.prop ); // Remember where we started, so that we can go back to it later this.options.orig[ this.prop ] = dataShow || jQuery.style( this.elem, this.prop ); this.options.show = true; // Begin the animation // Make sure that we start at a small width/height to avoid any flash of content if ( dataShow !== undefined ) { // This show is picking up where a previous hide or show left off this.custom( this.cur(), dataShow ); } else { this.custom( this.prop === "width" || this.prop === "height" ? 1 : 0, this.cur() ); } // Start by showing the element jQuery( this.elem ).show(); }, // Simple 'hide' function hide: function() { // Remember where we started, so that we can go back to it later this.options.orig[ this.prop ] = jQuery._data( this.elem, "fxshow" + this.prop ) || jQuery.style( this.elem, this.prop ); this.options.hide = true; // Begin the animation this.custom( this.cur(), 0 ); }, // Each step of an animation step: function( gotoEnd ) { var p, n, complete, t = fxNow || createFxNow(), done = true, elem = this.elem, options = this.options; if ( gotoEnd || t >= options.duration + this.startTime ) { this.now = this.end; this.pos = this.state = 1; this.update(); options.animatedProperties[ this.prop ] = true; for ( p in options.animatedProperties ) { if ( options.animatedProperties[ p ] !== true ) { done = false; } } if ( done ) { // Reset the overflow if ( options.overflow != null && !jQuery.support.shrinkWrapBlocks ) { jQuery.each( [ "", "X", "Y" ], function( index, value ) { elem.style[ "overflow" + value ] = options.overflow[ index ]; }); } // Hide the element if the "hide" operation was done if ( options.hide ) { jQuery( elem ).hide(); } // Reset the properties, if the item has been hidden or shown if ( options.hide || options.show ) { for ( p in options.animatedProperties ) { jQuery.style( elem, p, options.orig[ p ] ); jQuery.removeData( elem, "fxshow" + p, true ); // Toggle data is no longer needed jQuery.removeData( elem, "toggle" + p, true ); } } // Execute the complete function // in the event that the complete function throws an exception // we must ensure it won't be called twice. #5684 complete = options.complete; if ( complete ) { options.complete = false; complete.call( elem ); } } return false; } else { // classical easing cannot be used with an Infinity duration if ( options.duration == Infinity ) { this.now = t; } else { n = t - this.startTime; this.state = n / options.duration; // Perform the easing function, defaults to swing this.pos = jQuery.easing[ options.animatedProperties[this.prop] ]( this.state, n, 0, 1, options.duration ); this.now = this.start + ( (this.end - this.start) * this.pos ); } // Perform the next step of the animation this.update(); } return true; } }; jQuery.extend( jQuery.fx, { tick: function() { var timer, timers = jQuery.timers, i = 0; for ( ; i < timers.length; i++ ) { timer = timers[ i ]; // Checks the timer has not already been removed if ( !timer() && timers[ i ] === timer ) { timers.splice( i--, 1 ); } } if ( !timers.length ) { jQuery.fx.stop(); } }, interval: 13, stop: function() { clearInterval( timerId ); timerId = null; }, speeds: { slow: 600, fast: 200, // Default speed _default: 400 }, step: { opacity: function( fx ) { jQuery.style( fx.elem, "opacity", fx.now ); }, _default: function( fx ) { if ( fx.elem.style && fx.elem.style[ fx.prop ] != null ) { fx.elem.style[ fx.prop ] = fx.now + fx.unit; } else { fx.elem[ fx.prop ] = fx.now; } } } }); // Adds width/height step functions // Do not set anything below 0 jQuery.each([ "width", "height" ], function( i, prop ) { jQuery.fx.step[ prop ] = function( fx ) { jQuery.style( fx.elem, prop, Math.max(0, fx.now) + fx.unit ); }; }); if ( jQuery.expr && jQuery.expr.filters ) { jQuery.expr.filters.animated = function( elem ) { return jQuery.grep(jQuery.timers, function( fn ) { return elem === fn.elem; }).length; }; } // Try to restore the default display value of an element function defaultDisplay( nodeName ) { if ( !elemdisplay[ nodeName ] ) { var body = document.body, elem = jQuery( "<" + nodeName + ">" ).appendTo( body ), display = elem.css( "display" ); elem.remove(); // If the simple way fails, // get element's real default display by attaching it to a temp iframe if ( display === "none" || display === "" ) { // No iframe to use yet, so create it if ( !iframe ) { iframe = document.createElement( "iframe" ); iframe.frameBorder = iframe.width = iframe.height = 0; } body.appendChild( iframe ); // Create a cacheable copy of the iframe document on first call. // IE and Opera will allow us to reuse the iframeDoc without re-writing the fake HTML // document to it; WebKit & Firefox won't allow reusing the iframe document. if ( !iframeDoc || !iframe.createElement ) { iframeDoc = ( iframe.contentWindow || iframe.contentDocument ).document; iframeDoc.write( ( document.compatMode === "CSS1Compat" ? "<!doctype html>" : "" ) + "<html><body>" ); iframeDoc.close(); } elem = iframeDoc.createElement( nodeName ); iframeDoc.body.appendChild( elem ); display = jQuery.css( elem, "display" ); body.removeChild( iframe ); } // Store the correct default display elemdisplay[ nodeName ] = display; } return elemdisplay[ nodeName ]; } var rtable = /^t(?:able|d|h)$/i, rroot = /^(?:body|html)$/i; if ( "getBoundingClientRect" in document.documentElement ) { jQuery.fn.offset = function( options ) { var elem = this[0], box; if ( options ) { return this.each(function( i ) { jQuery.offset.setOffset( this, options, i ); }); } if ( !elem || !elem.ownerDocument ) { return null; } if ( elem === elem.ownerDocument.body ) { return jQuery.offset.bodyOffset( elem ); } try { box = elem.getBoundingClientRect(); } catch(e) {} var doc = elem.ownerDocument, docElem = doc.documentElement; // Make sure we're not dealing with a disconnected DOM node if ( !box || !jQuery.contains( docElem, elem ) ) { return box ? { top: box.top, left: box.left } : { top: 0, left: 0 }; } var body = doc.body, win = getWindow(doc), clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0, scrollTop = win.pageYOffset || jQuery.support.boxModel && docElem.scrollTop || body.scrollTop, scrollLeft = win.pageXOffset || jQuery.support.boxModel && docElem.scrollLeft || body.scrollLeft, top = box.top + scrollTop - clientTop, left = box.left + scrollLeft - clientLeft; return { top: top, left: left }; }; } else { jQuery.fn.offset = function( options ) { var elem = this[0]; if ( options ) { return this.each(function( i ) { jQuery.offset.setOffset( this, options, i ); }); } if ( !elem || !elem.ownerDocument ) { return null; } if ( elem === elem.ownerDocument.body ) { return jQuery.offset.bodyOffset( elem ); } var computedStyle, offsetParent = elem.offsetParent, prevOffsetParent = elem, doc = elem.ownerDocument, docElem = doc.documentElement, body = doc.body, defaultView = doc.defaultView, prevComputedStyle = defaultView ? defaultView.getComputedStyle( elem, null ) : elem.currentStyle, top = elem.offsetTop, left = elem.offsetLeft; while ( (elem = elem.parentNode) && elem !== body && elem !== docElem ) { if ( jQuery.support.fixedPosition && prevComputedStyle.position === "fixed" ) { break; } computedStyle = defaultView ? defaultView.getComputedStyle(elem, null) : elem.currentStyle; top -= elem.scrollTop; left -= elem.scrollLeft; if ( elem === offsetParent ) { top += elem.offsetTop; left += elem.offsetLeft; if ( jQuery.support.doesNotAddBorder && !(jQuery.support.doesAddBorderForTableAndCells && rtable.test(elem.nodeName)) ) { top += parseFloat( computedStyle.borderTopWidth ) || 0; left += parseFloat( computedStyle.borderLeftWidth ) || 0; } prevOffsetParent = offsetParent; offsetParent = elem.offsetParent; } if ( jQuery.support.subtractsBorderForOverflowNotVisible && computedStyle.overflow !== "visible" ) { top += parseFloat( computedStyle.borderTopWidth ) || 0; left += parseFloat( computedStyle.borderLeftWidth ) || 0; } prevComputedStyle = computedStyle; } if ( prevComputedStyle.position === "relative" || prevComputedStyle.position === "static" ) { top += body.offsetTop; left += body.offsetLeft; } if ( jQuery.support.fixedPosition && prevComputedStyle.position === "fixed" ) { top += Math.max( docElem.scrollTop, body.scrollTop ); left += Math.max( docElem.scrollLeft, body.scrollLeft ); } return { top: top, left: left }; }; } jQuery.offset = { bodyOffset: function( body ) { var top = body.offsetTop, left = body.offsetLeft; if ( jQuery.support.doesNotIncludeMarginInBodyOffset ) { top += parseFloat( jQuery.css(body, "marginTop") ) || 0; left += parseFloat( jQuery.css(body, "marginLeft") ) || 0; } return { top: top, left: left }; }, setOffset: function( elem, options, i ) { var position = jQuery.css( elem, "position" ); // set position first, in-case top/left are set even on static elem if ( position === "static" ) { elem.style.position = "relative"; } var curElem = jQuery( elem ), curOffset = curElem.offset(), curCSSTop = jQuery.css( elem, "top" ), curCSSLeft = jQuery.css( elem, "left" ), calculatePosition = ( position === "absolute" || position === "fixed" ) && jQuery.inArray("auto", [curCSSTop, curCSSLeft]) > -1, props = {}, curPosition = {}, curTop, curLeft; // need to be able to calculate position if either top or left is auto and position is either absolute or fixed if ( calculatePosition ) { curPosition = curElem.position(); curTop = curPosition.top; curLeft = curPosition.left; } else { curTop = parseFloat( curCSSTop ) || 0; curLeft = parseFloat( curCSSLeft ) || 0; } if ( jQuery.isFunction( options ) ) { options = options.call( elem, i, curOffset ); } if ( options.top != null ) { props.top = ( options.top - curOffset.top ) + curTop; } if ( options.left != null ) { props.left = ( options.left - curOffset.left ) + curLeft; } if ( "using" in options ) { options.using.call( elem, props ); } else { curElem.css( props ); } } }; jQuery.fn.extend({ position: function() { if ( !this[0] ) { return null; } var elem = this[0], // Get *real* offsetParent offsetParent = this.offsetParent(), // Get correct offsets offset = this.offset(), parentOffset = rroot.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset(); // Subtract element margins // note: when an element has margin: auto the offsetLeft and marginLeft // are the same in Safari causing offset.left to incorrectly be 0 offset.top -= parseFloat( jQuery.css(elem, "marginTop") ) || 0; offset.left -= parseFloat( jQuery.css(elem, "marginLeft") ) || 0; // Add offsetParent borders parentOffset.top += parseFloat( jQuery.css(offsetParent[0], "borderTopWidth") ) || 0; parentOffset.left += parseFloat( jQuery.css(offsetParent[0], "borderLeftWidth") ) || 0; // Subtract the two offsets return { top: offset.top - parentOffset.top, left: offset.left - parentOffset.left }; }, offsetParent: function() { return this.map(function() { var offsetParent = this.offsetParent || document.body; while ( offsetParent && (!rroot.test(offsetParent.nodeName) && jQuery.css(offsetParent, "position") === "static") ) { offsetParent = offsetParent.offsetParent; } return offsetParent; }); } }); // Create scrollLeft and scrollTop methods jQuery.each( ["Left", "Top"], function( i, name ) { var method = "scroll" + name; jQuery.fn[ method ] = function( val ) { var elem, win; if ( val === undefined ) { elem = this[ 0 ]; if ( !elem ) { return null; } win = getWindow( elem ); // Return the scroll offset return win ? ("pageXOffset" in win) ? win[ i ? "pageYOffset" : "pageXOffset" ] : jQuery.support.boxModel && win.document.documentElement[ method ] || win.document.body[ method ] : elem[ method ]; } // Set the scroll offset return this.each(function() { win = getWindow( this ); if ( win ) { win.scrollTo( !i ? val : jQuery( win ).scrollLeft(), i ? val : jQuery( win ).scrollTop() ); } else { this[ method ] = val; } }); }; }); function getWindow( elem ) { return jQuery.isWindow( elem ) ? elem : elem.nodeType === 9 ? elem.defaultView || elem.parentWindow : false; } // Create width, height, innerHeight, innerWidth, outerHeight and outerWidth methods jQuery.each([ "Height", "Width" ], function( i, name ) { var type = name.toLowerCase(); // innerHeight and innerWidth jQuery.fn[ "inner" + name ] = function() { var elem = this[0]; return elem ? elem.style ? parseFloat( jQuery.css( elem, type, "padding" ) ) : this[ type ]() : null; }; // outerHeight and outerWidth jQuery.fn[ "outer" + name ] = function( margin ) { var elem = this[0]; return elem ? elem.style ? parseFloat( jQuery.css( elem, type, margin ? "margin" : "border" ) ) : this[ type ]() : null; }; jQuery.fn[ type ] = function( size ) { // Get window width or height var elem = this[0]; if ( !elem ) { return size == null ? null : this; } if ( jQuery.isFunction( size ) ) { return this.each(function( i ) { var self = jQuery( this ); self[ type ]( size.call( this, i, self[ type ]() ) ); }); } if ( jQuery.isWindow( elem ) ) { // Everyone else use document.documentElement or document.body depending on Quirks vs Standards mode // 3rd condition allows Nokia support, as it supports the docElem prop but not CSS1Compat var docElemProp = elem.document.documentElement[ "client" + name ], body = elem.document.body; return elem.document.compatMode === "CSS1Compat" && docElemProp || body && body[ "client" + name ] || docElemProp; // Get document width or height } else if ( elem.nodeType === 9 ) { // Either scroll[Width/Height] or offset[Width/Height], whichever is greater return Math.max( elem.documentElement["client" + name], elem.body["scroll" + name], elem.documentElement["scroll" + name], elem.body["offset" + name], elem.documentElement["offset" + name] ); // Get or set width or height on the element } else if ( size === undefined ) { var orig = jQuery.css( elem, type ), ret = parseFloat( orig ); return jQuery.isNumeric( ret ) ? ret : orig; // Set the width or height on the element (default to pixels if value is unitless) } else { return this.css( type, typeof size === "string" ? size : size + "px" ); } }; }); // Expose jQuery to the global object window.jQuery = window.$ = jQuery; // Expose jQuery as an AMD module, but only for AMD loaders that // understand the issues with loading multiple versions of jQuery // in a page that all might call define(). The loader will indicate // they have special allowances for multiple jQuery versions by // specifying define.amd.jQuery = true. Register as a named module, // since jQuery can be concatenated with other files that may use define, // but not use a proper concatenation script that understands anonymous // AMD modules. A named AMD is safest and most robust way to register. // Lowercase jquery is used because AMD module names are derived from // file names, and jQuery is normally delivered in a lowercase file name. // Do this after creating the global so that if an AMD module wants to call // noConflict to hide this version of jQuery, it will work. if ( typeof define === "function" && define.amd && define.amd.jQuery ) { define( "jquery", [], function () { return jQuery; } ); } })( window ); ================================================ FILE: beetsplug/web/static/underscore.js ================================================ // Underscore.js 1.2.2 // (c) 2011 Jeremy Ashkenas, DocumentCloud Inc. // Underscore is freely distributable under the MIT license. // Portions of Underscore are inspired or borrowed from Prototype, // Oliver Steele's Functional, and John Resig's Micro-Templating. // For all details and documentation: // http://documentcloud.github.com/underscore (function() { // Baseline setup // -------------- // Establish the root object, `window` in the browser, or `global` on the server. var root = this; // Save the previous value of the `_` variable. var previousUnderscore = root._; // Establish the object that gets returned to break out of a loop iteration. var breaker = {}; // Save bytes in the minified (but not gzipped) version: var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; // Create quick reference variables for speed access to core prototypes. var slice = ArrayProto.slice, unshift = ArrayProto.unshift, toString = ObjProto.toString, hasOwnProperty = ObjProto.hasOwnProperty; // All **ECMAScript 5** native function implementations that we hope to use // are declared here. var nativeForEach = ArrayProto.forEach, nativeMap = ArrayProto.map, nativeReduce = ArrayProto.reduce, nativeReduceRight = ArrayProto.reduceRight, nativeFilter = ArrayProto.filter, nativeEvery = ArrayProto.every, nativeSome = ArrayProto.some, nativeIndexOf = ArrayProto.indexOf, nativeLastIndexOf = ArrayProto.lastIndexOf, nativeIsArray = Array.isArray, nativeKeys = Object.keys, nativeBind = FuncProto.bind; // Create a safe reference to the Underscore object for use below. var _ = function(obj) { return new wrapper(obj); }; // Export the Underscore object for **Node.js** and **"CommonJS"**, with // backwards-compatibility for the old `require()` API. If we're not in // CommonJS, add `_` to the global object. if (typeof exports !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { exports = module.exports = _; } exports._ = _; } else if (typeof define === 'function' && define.amd) { // Register as a named module with AMD. define('underscore', function() { return _; }); } else { // Exported as a string, for Closure Compiler "advanced" mode. root['_'] = _; } // Current version. _.VERSION = '1.2.2'; // Collection Functions // -------------------- // The cornerstone, an `each` implementation, aka `forEach`. // Handles objects with the built-in `forEach`, arrays, and raw objects. // Delegates to **ECMAScript 5**'s native `forEach` if available. var each = _.each = _.forEach = function(obj, iterator, context) { if (obj == null) return; if (nativeForEach && obj.forEach === nativeForEach) { obj.forEach(iterator, context); } else if (obj.length === +obj.length) { for (var i = 0, l = obj.length; i < l; i++) { if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) return; } } else { for (var key in obj) { if (hasOwnProperty.call(obj, key)) { if (iterator.call(context, obj[key], key, obj) === breaker) return; } } } }; // Return the results of applying the iterator to each element. // Delegates to **ECMAScript 5**'s native `map` if available. _.map = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); each(obj, function(value, index, list) { results[results.length] = iterator.call(context, value, index, list); }); return results; }; // **Reduce** builds up a single result from a list of values, aka `inject`, // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { var initial = memo !== void 0; if (obj == null) obj = []; if (nativeReduce && obj.reduce === nativeReduce) { if (context) iterator = _.bind(iterator, context); return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); } each(obj, function(value, index, list) { if (!initial) { memo = value; initial = true; } else { memo = iterator.call(context, memo, value, index, list); } }); if (!initial) throw new TypeError("Reduce of empty array with no initial value"); return memo; }; // The right-associative version of reduce, also known as `foldr`. // Delegates to **ECMAScript 5**'s native `reduceRight` if available. _.reduceRight = _.foldr = function(obj, iterator, memo, context) { if (obj == null) obj = []; if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { if (context) iterator = _.bind(iterator, context); return memo !== void 0 ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); } var reversed = (_.isArray(obj) ? obj.slice() : _.toArray(obj)).reverse(); return _.reduce(reversed, iterator, memo, context); }; // Return the first value which passes a truth test. Aliased as `detect`. _.find = _.detect = function(obj, iterator, context) { var result; any(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) { result = value; return true; } }); return result; }; // Return all the elements that pass a truth test. // Delegates to **ECMAScript 5**'s native `filter` if available. // Aliased as `select`. _.filter = _.select = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); each(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Return all the elements for which a truth test fails. _.reject = function(obj, iterator, context) { var results = []; if (obj == null) return results; each(obj, function(value, index, list) { if (!iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Determine whether all of the elements match a truth test. // Delegates to **ECMAScript 5**'s native `every` if available. // Aliased as `all`. _.every = _.all = function(obj, iterator, context) { var result = true; if (obj == null) return result; if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); each(obj, function(value, index, list) { if (!(result = result && iterator.call(context, value, index, list))) return breaker; }); return result; }; // Determine if at least one element in the object matches a truth test. // Delegates to **ECMAScript 5**'s native `some` if available. // Aliased as `any`. var any = _.some = _.any = function(obj, iterator, context) { iterator = iterator || _.identity; var result = false; if (obj == null) return result; if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); each(obj, function(value, index, list) { if (result || (result = iterator.call(context, value, index, list))) return breaker; }); return !!result; }; // Determine if a given value is included in the array or object using `===`. // Aliased as `contains`. _.include = _.contains = function(obj, target) { var found = false; if (obj == null) return found; if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; found = any(obj, function(value) { return value === target; }); return found; }; // Invoke a method (with arguments) on every item in a collection. _.invoke = function(obj, method) { var args = slice.call(arguments, 2); return _.map(obj, function(value) { return (method.call ? method || value : value[method]).apply(value, args); }); }; // Convenience version of a common use case of `map`: fetching a property. _.pluck = function(obj, key) { return _.map(obj, function(value){ return value[key]; }); }; // Return the maximum element or (element-based computation). _.max = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.max.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return -Infinity; var result = {computed : -Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed >= result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Return the minimum element (or element-based computation). _.min = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.min.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return Infinity; var result = {computed : Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed < result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Shuffle an array. _.shuffle = function(obj) { var shuffled = [], rand; each(obj, function(value, index, list) { if (index == 0) { shuffled[0] = value; } else { rand = Math.floor(Math.random() * (index + 1)); shuffled[index] = shuffled[rand]; shuffled[rand] = value; } }); return shuffled; }; // Sort the object's values by a criterion produced by an iterator. _.sortBy = function(obj, iterator, context) { return _.pluck(_.map(obj, function(value, index, list) { return { value : value, criteria : iterator.call(context, value, index, list) }; }).sort(function(left, right) { var a = left.criteria, b = right.criteria; return a < b ? -1 : a > b ? 1 : 0; }), 'value'); }; // Groups the object's values by a criterion. Pass either a string attribute // to group by, or a function that returns the criterion. _.groupBy = function(obj, val) { var result = {}; var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; each(obj, function(value, index) { var key = iterator(value, index); (result[key] || (result[key] = [])).push(value); }); return result; }; // Use a comparator function to figure out at what index an object should // be inserted so as to maintain order. Uses binary search. _.sortedIndex = function(array, obj, iterator) { iterator || (iterator = _.identity); var low = 0, high = array.length; while (low < high) { var mid = (low + high) >> 1; iterator(array[mid]) < iterator(obj) ? low = mid + 1 : high = mid; } return low; }; // Safely convert anything iterable into a real, live array. _.toArray = function(iterable) { if (!iterable) return []; if (iterable.toArray) return iterable.toArray(); if (_.isArray(iterable)) return slice.call(iterable); if (_.isArguments(iterable)) return slice.call(iterable); return _.values(iterable); }; // Return the number of elements in an object. _.size = function(obj) { return _.toArray(obj).length; }; // Array Functions // --------------- // Get the first element of an array. Passing **n** will return the first N // values in the array. Aliased as `head`. The **guard** check allows it to work // with `_.map`. _.first = _.head = function(array, n, guard) { return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; }; // Returns everything but the last entry of the array. Especcialy useful on // the arguments object. Passing **n** will return all the values in // the array, excluding the last N. The **guard** check allows it to work with // `_.map`. _.initial = function(array, n, guard) { return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); }; // Get the last element of an array. Passing **n** will return the last N // values in the array. The **guard** check allows it to work with `_.map`. _.last = function(array, n, guard) { if ((n != null) && !guard) { return slice.call(array, Math.max(array.length - n, 0)); } else { return array[array.length - 1]; } }; // Returns everything but the first entry of the array. Aliased as `tail`. // Especially useful on the arguments object. Passing an **index** will return // the rest of the values in the array from that index onward. The **guard** // check allows it to work with `_.map`. _.rest = _.tail = function(array, index, guard) { return slice.call(array, (index == null) || guard ? 1 : index); }; // Trim out all falsy values from an array. _.compact = function(array) { return _.filter(array, function(value){ return !!value; }); }; // Return a completely flattened version of an array. _.flatten = function(array, shallow) { return _.reduce(array, function(memo, value) { if (_.isArray(value)) return memo.concat(shallow ? value : _.flatten(value)); memo[memo.length] = value; return memo; }, []); }; // Return a version of the array that does not contain the specified value(s). _.without = function(array) { return _.difference(array, slice.call(arguments, 1)); }; // Produce a duplicate-free version of the array. If the array has already // been sorted, you have the option of using a faster algorithm. // Aliased as `unique`. _.uniq = _.unique = function(array, isSorted, iterator) { var initial = iterator ? _.map(array, iterator) : array; var result = []; _.reduce(initial, function(memo, el, i) { if (0 == i || (isSorted === true ? _.last(memo) != el : !_.include(memo, el))) { memo[memo.length] = el; result[result.length] = array[i]; } return memo; }, []); return result; }; // Produce an array that contains the union: each distinct element from all of // the passed-in arrays. _.union = function() { return _.uniq(_.flatten(arguments, true)); }; // Produce an array that contains every item shared between all the // passed-in arrays. (Aliased as "intersect" for back-compat.) _.intersection = _.intersect = function(array) { var rest = slice.call(arguments, 1); return _.filter(_.uniq(array), function(item) { return _.every(rest, function(other) { return _.indexOf(other, item) >= 0; }); }); }; // Take the difference between one array and another. // Only the elements present in just the first array will remain. _.difference = function(array, other) { return _.filter(array, function(value){ return !_.include(other, value); }); }; // Zip together multiple lists into a single array -- elements that share // an index go together. _.zip = function() { var args = slice.call(arguments); var length = _.max(_.pluck(args, 'length')); var results = new Array(length); for (var i = 0; i < length; i++) results[i] = _.pluck(args, "" + i); return results; }; // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), // we need this function. Return the position of the first occurrence of an // item in an array, or -1 if the item is not included in the array. // Delegates to **ECMAScript 5**'s native `indexOf` if available. // If the array is large and already in sort order, pass `true` // for **isSorted** to use binary search. _.indexOf = function(array, item, isSorted) { if (array == null) return -1; var i, l; if (isSorted) { i = _.sortedIndex(array, item); return array[i] === item ? i : -1; } if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item); for (i = 0, l = array.length; i < l; i++) if (array[i] === item) return i; return -1; }; // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. _.lastIndexOf = function(array, item) { if (array == null) return -1; if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) return array.lastIndexOf(item); var i = array.length; while (i--) if (array[i] === item) return i; return -1; }; // Generate an integer Array containing an arithmetic progression. A port of // the native Python `range()` function. See // [the Python documentation](http://docs.python.org/library/functions.html#range). _.range = function(start, stop, step) { if (arguments.length <= 1) { stop = start || 0; start = 0; } step = arguments[2] || 1; var len = Math.max(Math.ceil((stop - start) / step), 0); var idx = 0; var range = new Array(len); while(idx < len) { range[idx++] = start; start += step; } return range; }; // Function (ahem) Functions // ------------------ // Reusable constructor function for prototype setting. var ctor = function(){}; // Create a function bound to a given object (assigning `this`, and arguments, // optionally). Binding with arguments is also known as `curry`. // Delegates to **ECMAScript 5**'s native `Function.bind` if available. // We check for `func.bind` first, to fail fast when `func` is undefined. _.bind = function bind(func, context) { var bound, args; if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); if (!_.isFunction(func)) throw new TypeError; args = slice.call(arguments, 2); return bound = function() { if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); ctor.prototype = func.prototype; var self = new ctor; var result = func.apply(self, args.concat(slice.call(arguments))); if (Object(result) === result) return result; return self; }; }; // Bind all of an object's methods to that object. Useful for ensuring that // all callbacks defined on an object belong to it. _.bindAll = function(obj) { var funcs = slice.call(arguments, 1); if (funcs.length == 0) funcs = _.functions(obj); each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); return obj; }; // Memoize an expensive function by storing its results. _.memoize = function(func, hasher) { var memo = {}; hasher || (hasher = _.identity); return function() { var key = hasher.apply(this, arguments); return hasOwnProperty.call(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); }; }; // Delays a function for the given number of milliseconds, and then calls // it with the arguments supplied. _.delay = function(func, wait) { var args = slice.call(arguments, 2); return setTimeout(function(){ return func.apply(func, args); }, wait); }; // Defers a function, scheduling it to run after the current call stack has // cleared. _.defer = function(func) { return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); }; // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. _.throttle = function(func, wait) { var context, args, timeout, throttling, more; var whenDone = _.debounce(function(){ more = throttling = false; }, wait); return function() { context = this; args = arguments; var later = function() { timeout = null; if (more) func.apply(context, args); whenDone(); }; if (!timeout) timeout = setTimeout(later, wait); if (throttling) { more = true; } else { func.apply(context, args); } whenDone(); throttling = true; }; }; // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. _.debounce = function(func, wait) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; func.apply(context, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; // Returns a function that will be executed at most one time, no matter how // often you call it. Useful for lazy initialization. _.once = function(func) { var ran = false, memo; return function() { if (ran) return memo; ran = true; return memo = func.apply(this, arguments); }; }; // Returns the first function passed as an argument to the second, // allowing you to adjust arguments, run code before and after, and // conditionally execute the original function. _.wrap = function(func, wrapper) { return function() { var args = [func].concat(slice.call(arguments)); return wrapper.apply(this, args); }; }; // Returns a function that is the composition of a list of functions, each // consuming the return value of the function that follows. _.compose = function() { var funcs = slice.call(arguments); return function() { var args = slice.call(arguments); for (var i = funcs.length - 1; i >= 0; i--) { args = [funcs[i].apply(this, args)]; } return args[0]; }; }; // Returns a function that will only be executed after being called N times. _.after = function(times, func) { if (times <= 0) return func(); return function() { if (--times < 1) { return func.apply(this, arguments); } }; }; // Object Functions // ---------------- // Retrieve the names of an object's properties. // Delegates to **ECMAScript 5**'s native `Object.keys` _.keys = nativeKeys || function(obj) { if (obj !== Object(obj)) throw new TypeError('Invalid object'); var keys = []; for (var key in obj) if (hasOwnProperty.call(obj, key)) keys[keys.length] = key; return keys; }; // Retrieve the values of an object's properties. _.values = function(obj) { return _.map(obj, _.identity); }; // Return a sorted list of the function names available on the object. // Aliased as `methods` _.functions = _.methods = function(obj) { var names = []; for (var key in obj) { if (_.isFunction(obj[key])) names.push(key); } return names.sort(); }; // Extend a given object with all the properties in passed-in object(s). _.extend = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (source[prop] !== void 0) obj[prop] = source[prop]; } }); return obj; }; // Fill in a given object with default properties. _.defaults = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (obj[prop] == null) obj[prop] = source[prop]; } }); return obj; }; // Create a (shallow-cloned) duplicate of an object. _.clone = function(obj) { if (!_.isObject(obj)) return obj; return _.isArray(obj) ? obj.slice() : _.extend({}, obj); }; // Invokes interceptor with the obj, and then returns obj. // The primary purpose of this method is to "tap into" a method chain, in // order to perform operations on intermediate results within the chain. _.tap = function(obj, interceptor) { interceptor(obj); return obj; }; // Internal recursive comparison function. function eq(a, b, stack) { // Identical objects are equal. `0 === -0`, but they aren't identical. // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. if (a === b) return a !== 0 || 1 / a == 1 / b; // A strict comparison is necessary because `null == undefined`. if (a == null || b == null) return a === b; // Unwrap any wrapped objects. if (a._chain) a = a._wrapped; if (b._chain) b = b._wrapped; // Invoke a custom `isEqual` method if one is provided. if (_.isFunction(a.isEqual)) return a.isEqual(b); if (_.isFunction(b.isEqual)) return b.isEqual(a); // Compare `[[Class]]` names. var className = toString.call(a); if (className != toString.call(b)) return false; switch (className) { // Strings, numbers, dates, and booleans are compared by value. case '[object String]': // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is // equivalent to `new String("5")`. return String(a) == String(b); case '[object Number]': a = +a; b = +b; // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for // other numeric values. return a != a ? b != b : (a == 0 ? 1 / a == 1 / b : a == b); case '[object Date]': case '[object Boolean]': // Coerce dates and booleans to numeric primitive values. Dates are compared by their // millisecond representations. Note that invalid dates with millisecond representations // of `NaN` are not equivalent. return +a == +b; // RegExps are compared by their source patterns and flags. case '[object RegExp]': return a.source == b.source && a.global == b.global && a.multiline == b.multiline && a.ignoreCase == b.ignoreCase; } if (typeof a != 'object' || typeof b != 'object') return false; // Assume equality for cyclic structures. The algorithm for detecting cyclic // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. var length = stack.length; while (length--) { // Linear search. Performance is inversely proportional to the number of // unique nested structures. if (stack[length] == a) return true; } // Add the first object to the stack of traversed objects. stack.push(a); var size = 0, result = true; // Recursively compare objects and arrays. if (className == '[object Array]') { // Compare array lengths to determine if a deep comparison is necessary. size = a.length; result = size == b.length; if (result) { // Deep compare the contents, ignoring non-numeric properties. while (size--) { // Ensure commutative equality for sparse arrays. if (!(result = size in a == size in b && eq(a[size], b[size], stack))) break; } } } else { // Objects with different constructors are not equivalent. if ("constructor" in a != "constructor" in b || a.constructor != b.constructor) return false; // Deep compare objects. for (var key in a) { if (hasOwnProperty.call(a, key)) { // Count the expected number of properties. size++; // Deep compare each member. if (!(result = hasOwnProperty.call(b, key) && eq(a[key], b[key], stack))) break; } } // Ensure that both objects contain the same number of properties. if (result) { for (key in b) { if (hasOwnProperty.call(b, key) && !(size--)) break; } result = !size; } } // Remove the first object from the stack of traversed objects. stack.pop(); return result; } // Perform a deep comparison to check if two objects are equal. _.isEqual = function(a, b) { return eq(a, b, []); }; // Is a given array, string, or object empty? // An "empty" object has no enumerable own-properties. _.isEmpty = function(obj) { if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; for (var key in obj) if (hasOwnProperty.call(obj, key)) return false; return true; }; // Is a given value a DOM element? _.isElement = function(obj) { return !!(obj && obj.nodeType == 1); }; // Is a given value an array? // Delegates to ECMA5's native Array.isArray _.isArray = nativeIsArray || function(obj) { return toString.call(obj) == '[object Array]'; }; // Is a given variable an object? _.isObject = function(obj) { return obj === Object(obj); }; // Is a given variable an arguments object? if (toString.call(arguments) == '[object Arguments]') { _.isArguments = function(obj) { return toString.call(obj) == '[object Arguments]'; }; } else { _.isArguments = function(obj) { return !!(obj && hasOwnProperty.call(obj, 'callee')); }; } // Is a given value a function? _.isFunction = function(obj) { return toString.call(obj) == '[object Function]'; }; // Is a given value a string? _.isString = function(obj) { return toString.call(obj) == '[object String]'; }; // Is a given value a number? _.isNumber = function(obj) { return toString.call(obj) == '[object Number]'; }; // Is the given value `NaN`? _.isNaN = function(obj) { // `NaN` is the only value for which `===` is not reflexive. return obj !== obj; }; // Is a given value a boolean? _.isBoolean = function(obj) { return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; }; // Is a given value a date? _.isDate = function(obj) { return toString.call(obj) == '[object Date]'; }; // Is the given value a regular expression? _.isRegExp = function(obj) { return toString.call(obj) == '[object RegExp]'; }; // Is a given value equal to null? _.isNull = function(obj) { return obj === null; }; // Is a given variable undefined? _.isUndefined = function(obj) { return obj === void 0; }; // Utility Functions // ----------------- // Run Underscore.js in *noConflict* mode, returning the `_` variable to its // previous owner. Returns a reference to the Underscore object. _.noConflict = function() { root._ = previousUnderscore; return this; }; // Keep the identity function around for default iterators. _.identity = function(value) { return value; }; // Run a function **n** times. _.times = function (n, iterator, context) { for (var i = 0; i < n; i++) iterator.call(context, i); }; // Escape a string for HTML interpolation. _.escape = function(string) { return (''+string).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); }; // Add your own custom functions to the Underscore object, ensuring that // they're correctly added to the OOP wrapper as well. _.mixin = function(obj) { each(_.functions(obj), function(name){ addToWrapper(name, _[name] = obj[name]); }); }; // Generate a unique integer id (unique within the entire client session). // Useful for temporary DOM ids. var idCounter = 0; _.uniqueId = function(prefix) { var id = idCounter++; return prefix ? prefix + id : id; }; // By default, Underscore uses ERB-style template delimiters, change the // following template settings to use alternative delimiters. _.templateSettings = { evaluate : /<%([\s\S]+?)%>/g, interpolate : /<%=([\s\S]+?)%>/g, escape : /<%-([\s\S]+?)%>/g }; // JavaScript micro-templating, similar to John Resig's implementation. // Underscore templating handles arbitrary delimiters, preserves whitespace, // and correctly escapes quotes within interpolated code. _.template = function(str, data) { var c = _.templateSettings; var tmpl = 'var __p=[],print=function(){__p.push.apply(__p,arguments);};' + 'with(obj||{}){__p.push(\'' + str.replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(c.escape, function(match, code) { return "',_.escape(" + code.replace(/\\'/g, "'") + "),'"; }) .replace(c.interpolate, function(match, code) { return "'," + code.replace(/\\'/g, "'") + ",'"; }) .replace(c.evaluate || null, function(match, code) { return "');" + code.replace(/\\'/g, "'") .replace(/[\r\n\t]/g, ' ') + ";__p.push('"; }) .replace(/\r/g, '\\r') .replace(/\n/g, '\\n') .replace(/\t/g, '\\t') + "');}return __p.join('');"; var func = new Function('obj', '_', tmpl); return data ? func(data, _) : function(data) { return func(data, _) }; }; // The OOP Wrapper // --------------- // If Underscore is called as a function, it returns a wrapped object that // can be used OO-style. This wrapper holds altered versions of all the // underscore functions. Wrapped objects may be chained. var wrapper = function(obj) { this._wrapped = obj; }; // Expose `wrapper.prototype` as `_.prototype` _.prototype = wrapper.prototype; // Helper function to continue chaining intermediate results. var result = function(obj, chain) { return chain ? _(obj).chain() : obj; }; // A method to easily add functions to the OOP wrapper. var addToWrapper = function(name, func) { wrapper.prototype[name] = function() { var args = slice.call(arguments); unshift.call(args, this._wrapped); return result(func.apply(_, args), this._chain); }; }; // Add all of the Underscore functions to the wrapper object. _.mixin(_); // Add all mutator Array functions to the wrapper. each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { method.apply(this._wrapped, arguments); return result(this._wrapped, this._chain); }; }); // Add all accessor Array functions to the wrapper. each(['concat', 'join', 'slice'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { return result(method.apply(this._wrapped, arguments), this._chain); }; }); // Start chaining a wrapped Underscore object. wrapper.prototype.chain = function() { this._chain = true; return this; }; // Extracts the result from a wrapped and chained object. wrapper.prototype.value = function() { return this._wrapped; }; }).call(this); ================================================ FILE: beetsplug/web/templates/index.html ================================================ <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="description" content="the music geek’s media organizer"> <meta name="keywords" content="beets, media, music, library, metadata, player, tagger, grep, transcoder, organizer"> <title>beets
================================================ FILE: beetsplug/zero.py ================================================ # This file is part of beets. # Copyright 2016, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Clears tag fields in media files.""" import re import confuse from mediafile import MediaFile from beets.importer import Action from beets.plugins import BeetsPlugin from beets.ui import Subcommand, input_yn __author__ = "baobab@heresiarch.info" class ZeroPlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener("write", self.write_event) self.register_listener( "import_task_choice", self.import_task_choice_event ) self.config.add( { "auto": True, "fields": [], "keep_fields": [], "update_database": False, "omit_single_disc": False, } ) self.fields_to_progs = {} self.warned = False """Read the bulk of the config into `self.fields_to_progs`. After construction, `fields_to_progs` contains all the fields that should be zeroed as keys and maps each of those to a list of compiled regexes (progs) as values. A field is zeroed if its value matches one of the associated progs. If progs is empty, then the associated field is always zeroed. """ if self.config["fields"] and self.config["keep_fields"]: self._log.warning("cannot blacklist and whitelist at the same time") # Blacklist mode. elif self.config["fields"]: for field in self.config["fields"].as_str_seq(): self._set_pattern(field) # Whitelist mode. elif self.config["keep_fields"]: for field in MediaFile.fields(): if ( field not in self.config["keep_fields"].as_str_seq() and # These fields should always be preserved. field not in ("id", "path", "album_id") ): self._set_pattern(field) def commands(self): zero_command = Subcommand("zero", help="set fields to null") def zero_fields(lib, opts, args): if not args and not input_yn( "Remove fields for all items? (Y/n)", True ): return for item in lib.items(args): self.process_item(item) zero_command.func = zero_fields return [zero_command] def _set_pattern(self, field): """Populate `self.fields_to_progs` for a given field. Do some sanity checks then compile the regexes. """ if field not in MediaFile.fields(): self._log.error("invalid field: {}", field) elif field in ("id", "path", "album_id"): self._log.warning( "field '{}' ignored, zeroing it would be dangerous", field ) else: try: for pattern in self.config[field].as_str_seq(): prog = re.compile(pattern, re.IGNORECASE) self.fields_to_progs.setdefault(field, []).append(prog) except confuse.NotFoundError: # Matches everything self.fields_to_progs[field] = [] def import_task_choice_event(self, session, task): if task.choice_flag == Action.ASIS and not self.warned: self._log.warning('cannot zero in "as-is" mode') self.warned = True # TODO request write in as-is mode def write_event(self, item, path, tags): if self.config["auto"]: self.set_fields(item, tags) def set_fields(self, item, tags): """Set values in `tags` to `None` if the field is in `self.fields_to_progs` and any of the corresponding `progs` matches the field value. Also update the `item` itself if `update_database` is set in the config. """ fields_set = False if self.config["omit_single_disc"].get(bool) and item.disctotal == 1: for tag in {"disc", "disctotal"} & set(tags): tags[tag] = None fields_set = True if not self.fields_to_progs: self._log.warning("no fields list to remove") for field, progs in self.fields_to_progs.items(): if field in tags: value = tags[field] match = _match_progs(tags[field], progs) else: value = "" match = not progs if match: fields_set = True self._log.debug("{}: {} -> None", field, value) tags[field] = None if self.config["update_database"]: item[field] = None return fields_set def process_item(self, item): tags = dict(item) if self.set_fields(item, tags): item.write(tags=tags) if self.config["update_database"]: item.store(fields=tags) def _match_progs(value, progs): """Check if `value` (as string) is matching any of the compiled regexes in the `progs` list. """ if not progs: return True for prog in progs: if prog.search(str(value)): return True return False ================================================ FILE: codecov.yml ================================================ comment: layout: "header, diff, files" require_changes: true # Sets non-blocking status checks # https://docs.codecov.com/docs/commit-status#informational coverage: status: project: default: informational: true patch: default: informational: true changes: false ================================================ FILE: docs/.gitignore ================================================ _build generated/ ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build SOURCEDIR = . # When both are available, use Sphinx 2.x for autodoc compatibility. ifeq ($(shell which sphinx-build2 >/dev/null 2>&1 ; echo $$?),0) SPHINXBUILD = sphinx-build2 endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest auto help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* $(SOURCEDIR)/api/generated/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/beets.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/beets.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/beets" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/beets" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." ================================================ FILE: docs/_static/beets.css ================================================ html[data-theme="light"] { --pst-color-secondary: #a23632; } html[data-theme="light"] { --pst-color-inline-code: #a23632; } /* beetroot red: #a23632 */ /* beetroot green: #1B5801 */ /* beetroot green light: rgb(27, 150, 50) */ /* pydata teal (primary): #126A7E */ /* pydata violet (secondary): #7D0E70 */ ================================================ FILE: docs/_templates/autosummary/base.rst ================================================ {{ fullname | escape | underline}} .. currentmodule:: {{ module }} .. auto{{ objtype }}:: {{ objname }} ================================================ FILE: docs/_templates/autosummary/class.rst ================================================ {{ name | escape | underline}} .. currentmodule:: {{ module }} .. autoclass:: {{ objname }} :members: <-- add at least this line :private-members: :show-inheritance: <-- plus I want to show inheritance... :inherited-members: <-- ...and inherited members too {% block methods %} .. automethod:: __init__ {% if methods %} .. rubric:: {{ _('Public methods summary') }} .. autosummary:: {% for item in methods %} ~{{ name }}.{{ item }} {%- endfor %} {% for item in _methods %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} .. rubric:: {{ _('Methods definition') }} {% if objname in related_typeddicts %} Related TypedDicts ------------------ {% for typeddict in related_typeddicts[objname] %} .. autotypeddict:: {{ typeddict }} :show-inheritance: {% endfor %} {% endif %} ================================================ FILE: docs/_templates/autosummary/module.rst ================================================ {{ fullname | escape | underline}} {% block modules %} {% if modules %} .. rubric:: Modules {% for item in modules %} {{ item }} {%- endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/_templates/autosummary/namedtuple.rst ================================================ {{ name | escape | underline }} .. currentmodule:: {{ module }} .. autonamedtuple:: {{ objname }} ================================================ FILE: docs/api/database.rst ================================================ Database ======== .. currentmodule:: beets.library Library ------- .. autosummary:: :toctree: generated/ Library Models ------ .. autosummary:: :toctree: generated/ LibModel Album Item Transactions ------------ .. currentmodule:: beets.dbcore.db .. autosummary:: :toctree: generated/ Migration Transaction Queries ------- .. currentmodule:: beets.dbcore.query .. autosummary:: :toctree: generated/ Query FieldQuery AndQuery ================================================ FILE: docs/api/index.rst ================================================ API Reference ============= .. toctree:: :maxdepth: 2 :titlesonly: plugins plugin_utilities database ================================================ FILE: docs/api/plugin_utilities.rst ================================================ Plugin Utilities ================ .. currentmodule:: beetsplug._utils.requests .. autosummary:: :toctree: generated/ RequestHandler .. currentmodule:: beetsplug._utils.musicbrainz .. autosummary:: :toctree: generated/ MusicBrainzAPI ================================================ FILE: docs/api/plugins.rst ================================================ Plugins ======= .. currentmodule:: beets.plugins .. autosummary:: :toctree: generated/ BeetsPlugin .. currentmodule:: beets.metadata_plugins .. autosummary:: :toctree: generated/ MetadataSourcePlugin SearchApiMetadataSourcePlugin SearchParams ================================================ FILE: docs/changelog.rst ================================================ Changelog ========= Changelog goes here! Please add your entry to the bottom of one of the lists below! .. Uncomment the relevant section when you add the first entry Unreleased ---------- New features ~~~~~~~~~~~~ - :doc:`plugins/discogs`: Add :conf:`plugins.discogs:extra_tags` option to use additional tags (such as ``barcode``, ``catalognum``, ``country``, ``label``, ``media``, and ``year``) in Discogs search queries. - :doc:`plugins/smartplaylist`: Add new configuration option ``dest_regen`` to regenerate items' path in the generated playlist instead of using those in the library. This is useful when items have been imported in don't copy-move (``-C -M``) mode in the library but are later passed through the ``convert`` plugin which will regenerate new paths according to the Beets path format. - :doc:`plugins/missing`: When running in missing album mode, allows users to specify MusicBrainz release types to show using the ``--release-type`` flag. The default behavior is also changed to just show releases of type ``album``. :bug:`2661` - :doc:`plugins/play`: Added ``-R``/``--randomize`` flag to shuffle the playlist order before passing it to the player. Bug fixes ~~~~~~~~~ - :doc:`plugins/missing`: Fix ``--album`` mode incorrectly reporting albums already in the library as missing. The comparison now correctly uses ``mb_releasegroupid``. - :ref:`replace`: Made ``drive_sep_replace`` regex logic more precise to prevent edge-case mismatches (e.g., a song titled "1:00 AM" would incorrectly be considered a Windows drive path). - :doc:`plugins/fish`: Fix AttributeError. :bug:`6340` - :ref:`import-cmd` Autotagging by explicit release or recording IDs now keeps candidates from all enabled metadata sources instead of dropping matches when different providers share the same ID. :bug:`6178` :bug:`6181` - :doc:`plugins/mbsync` and :doc:`plugins/missing` now use each item's stored ``data_source`` for ID lookups, with a fallback to ``MusicBrainz``. - :doc:`plugins/musicbrainz`: Use ``va_name`` config for ``albumartist_sort``, ``albumartists_sort``, ``albumartist_credit``, ``albumartists_credit``, and ``albumartists`` on VA releases instead of hardcoded "Various Artists". :bug:`6316` - :doc:`plugins/beatport`: Use ``va_name`` config for the album artist on VA releases instead of hardcoded "Various Artists". :bug:`6316` - :ref:`config-cmd` on Windows now uses ``cmd /c start ""`` for the default editor fallback so ``beet config -e`` works when ``VISUAL`` and ``EDITOR`` are unset. :bug:`6436` - :doc:`plugins/lastimport`: Rename flexible field ``play_count`` to ``lastfm_play_count`` to avoid conflicts with :doc:`plugins/mpdstats`. **Migration**: This cannot be migrated automatically because of the field clash. If you use ``lastimport`` without ``mpdstats``, migrate manually with ``beet modify lastfm_play_count='$play_count'``. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - :py:func:`beets.metadata_plugins.album_for_id` and :py:func:`beets.metadata_plugins.track_for_id` now require a ``data_source`` argument and query only that provider. - Colorisation, diff and layout utility helpers previously imported from :mod:`beets.ui` now live in :mod:`beets.util.color`, :mod:`beets.util.diff`, and :mod:`beets.util.layout`. Update external imports accordingly. - The ``tunelog`` logging helper that was exclusively available to the lastgenre plugin is now usable througout beets and was renamed to ``extra_debug``. Import it from the ``beets.logging`` module to use it. Other changes ~~~~~~~~~~~~~ - Deprecate the :doc:`plugins/beatport` and :doc:`plugins/bpsync` plugins. Beatport has retired the API these plugins rely on, making them non-functional. :bug:`3862` - API-backed metadata source plugins can now use :py:class:`~beets.metadata_plugins.SearchApiMetadataSourcePlugin` for shared search orchestration. Implement provider behavior in :py:meth:`~beets.metadata_plugins.SearchApiMetadataSourcePlugin.get_search_query_with_filters` and :py:meth:`~beets.metadata_plugins.SearchApiMetadataSourcePlugin.get_search_response`. - :doc:`guides/installation`: Remove redundant macOS section from the installation guide. :bug:`5993` - :doc:`guides/installation`: Update installation guide to document plugin management with pipx and move package manager instructions to the FAQ. - :doc:`guides/main`: Update quick installation section to reflect current installation guide structure. 2.7.1 (March 08, 2026) ---------------------- Bug fixes ~~~~~~~~~ - Tests that depend on the optional ``langdetect`` package are now skipped when the package is not installed. :bug:`6421` 2.7.0 (March 07, 2026) ---------------------- New features ~~~~~~~~~~~~ - :doc:`plugins/lastgenre`: Added ``cleanup_existing`` configuration flag to allow whitelist canonicalization of existing genres. - Add native support for multiple genres per album/track. The ``genres`` field now stores genres as a list and is written to files as multiple individual genre tags (e.g., separate GENRE tags for FLAC/MP3). The :doc:`plugins/musicbrainz`, :doc:`plugins/beatport`, :doc:`plugins/discogs` and :doc:`plugins/lastgenre` plugins have been updated to populate the ``genres`` field as a list. **Migration**: Existing libraries with comma-separated, semicolon-separated, or slash-separated genre strings (e.g., ``"Rock, Alternative, Indie"``) are automatically migrated to the ``genres`` list when you first run beets after upgrading. The migration runs once when the database schema is updated, splitting genre strings and writing the changes to the database. The updated ``genres`` values will be written to media files the next time you run a command that writes tags (such as ``beet write`` or during import). No manual action or ``mbsync`` is required. The ``genre`` field is split by the first separator found in the string, in the following order of precedence: 1. :doc:`plugins/lastgenre` ``separator`` configuration 2. Semicolon followed by a space 3. Comma followed by a space 4. Slash wrapped by spaces - :doc:`plugins/lyrics`: With ``synced`` enabled, existing synced lyrics are no longer replaced by newly fetched plain lyrics, even when ``force`` is enabled. - :doc:`plugins/lyrics`: Remove ``Source: `` suffix from lyrics. Store the backend name in ``lyrics_backend``, URL in ``lyrics_url``, language in ``lyrics_language`` and translation language (if translations present) in ``lyrics_translation_language`` flexible attributes. Lyrics are automatically migrated on the first beets run. :bug:`6370` Bug fixes ~~~~~~~~~ - :doc:`plugins/ftintitle`: Fix handling of multiple featured artists with ampersand. - :doc:`plugins/zero`: When the ``omit_single_disc`` option is set, ``disctotal`` is zeroed alongside ``disc``. - :doc:`plugins/fetchart`: Prevent deletion of configured fallback cover art - :ref:`import-cmd` When autotagging, initialise empty multi-valued fields with ``None`` instead of empty list, which caused beets to overwrite existing metadata with empty list values instead of leaving them unchanged. :bug:`6403` - :doc:`plugins/fuzzy`: Improve fuzzy matching when the query is shorter than the field value so substring-style searches produce more useful results. :bug:`2043` - :doc:`plugins/fuzzy`: Force slow query evaluation whenever the fuzzy prefix is used (for example ``~foo`` or ``%%foo``), so fuzzy matching is applied consistently. :bug:`5638` - :ref:`import-cmd` Duplicate detection now works for as-is imports (when ``autotag`` is disabled). Previously, ``duplicate_keys`` and ``duplicate_action`` config options were silently ignored for as-is imports. - :doc:`/plugins/convert`: Fix extension substitution inside path of the exported playlist. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - If you maintain a metadata source plugin that populates the ``genre`` field, please update it to populate a list of ``genres`` instead. You will see a deprecation warning for now, but support for populating the single ``genre`` field will be removed in version ``3.0.0``. Other changes ~~~~~~~~~~~~~ - :ref:`modify-cmd`: Use the following separator to delimit multiple field values: |semicolon_space|. For example ``beet modify albumtypes="album; ep"``. Previously, ``\␀`` was used as a separator. This applies to fields such as ``artists``, ``albumtypes`` etc. - Improve highlighting of multi-valued fields changes. - :doc:`plugins/edit`: Editing multi-valued fields now behaves more naturally, with list values handled directly to make metadata edits smoother and more predictable. - :doc:`plugins/lastgenre`: The ``separator`` configuration option is removed. Since genres are now stored as a list in the ``genres`` field and written to files as individual genre tags, this option has no effect and has been removed. - :doc:`plugins/lyrics`: To cut down noise from the ``lrclib`` lyrics source, synced lyrics are now checked to ensure the final verse falls within the track's duration. - Updated URLs in the documentation to use HTTPS where possible and updated outdated links. 2.6.2 (February 22, 2026) ------------------------- Bug fixes ~~~~~~~~~ - :doc:`plugins/musicbrainz`: Fix crash when release mediums lack the ``tracks`` key. :bug:`6302` - :doc:`plugins/musicbrainz`: Fix search terms escaping. :bug:`6347` - :doc:`plugins/musicbrainz`: Fix support for ``alias`` and ``tracks`` :conf:`plugins.musicbrainz:extra_tags`. - :doc:`plugins/musicbrainz`: Fix fetching very large releases that have more than 500 tracks. :bug:`6355` - :doc:`plugins/badfiles`: Fix number of found errors in log message - :doc:`plugins/replaygain`: Avoid magic Windows prefix in calls to command backends, such as ``mp3gain``. :bug:`2946` - :doc:`plugins/mbpseudo`: Fix crash due to missing ``artist_credit`` field in the MusicBrainz API response. :bug:`6339` - :ref:`config-cmd`: Improved error message when user-configured editor does not exist. :bug:`6176` Other changes ~~~~~~~~~~~~~ - :doc:`plugins/lyrics`: Disable ``tekstowo`` by default because it blocks the beets User-Agent. 2.6.1 (February 02, 2026) ------------------------- Bug fixes ~~~~~~~~~ - Make ``packaging`` a required dependency. :bug:`6332` 2.6.0 (February 01, 2026) ------------------------- Beets now requires Python 3.10 or later since support for EOL Python 3.9 has been dropped. New features ~~~~~~~~~~~~ - :doc:`plugins/fetchart`: Added config setting for a fallback cover art image. - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. - :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the genres tag. - :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and album artist are the same in ftintitle. - :doc:`plugins/play`: Added ``$playlist`` marker to precisely edit the playlist filepath into the command calling the player program. - :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. - :doc:`plugins/importsource`: Added new plugin that tracks original import paths and optionally suggests removing source files when items are removed from the library. - :doc:`plugins/mbpseudo`: Add a new ``mbpseudo`` plugin to proactively receive MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. - :doc:`/plugins/convert`: ``force`` can be passed to override checks like no_convert, never_convert_lossy_files, same format, and max_bitrate - :doc:`plugins/titlecase`: Add the ``titlecase`` plugin to allow users to resolve differences in metadata source styles. - :doc:`plugins/spotify`: Added support for multi-artist albums and tracks, saving all contributing artists to the respective fields. - :doc:`plugins/fetchart`: Fix colorized output text. - :doc:`plugins/ftintitle`: Featured artists are now inserted before brackets containing remix/edit-related keywords (e.g., "Remix", "Live", "Edit") instead of being appended at the end. This improves formatting for titles like "Song 1 (Carol Remix) ft. Bob" which becomes "Song 1 ft. Bob (Carol Remix)". A variety of brackets are supported and a new ``bracket_keywords`` configuration option allows customizing the keywords. Setting ``bracket_keywords`` to an empty list matches any bracket content regardless of keywords. - :doc:`plugins/discogs`: Added support for multi value fields. :bug:`6068` - :doc:`plugins/embedart`: Embedded arts can now be cleared during import with the ``clearart_on_import`` config option. Also, ``beet clearart`` is only going to update the files matching the query and with an embedded art, leaving untouched the files without. - :doc:`plugins/fish`: Filenames are now completed in more places, like after ``beet import``. - :doc:`plugins/random`: Added ``--field`` option to specify which field to use for equal-chance sampling (default: ``albumartist``). - :doc:`plugins/musicbrainz`: Use title aliases for releases, release groups, and recordings. Bug fixes ~~~~~~~~~ - :doc:`/plugins/lastgenre`: Canonicalize genres when ``force`` and ``keep_existing`` are ``on``, yet no genre info on lastfm could be found. :bug:`6303` - Handle potential OSError when unlinking temporary files in ArtResizer. :bug:`5615` - :doc:`/plugins/spotify`: Updated Spotify API credentials. :bug:`6270` - :doc:`/plugins/smartplaylist`: Fixed an issue where multiple queries in a playlist configuration were not preserving their order, causing items to appear in database order rather than the order specified in the config. :bug:`6183` - :doc:`plugins/inline`: Fix recursion error when an inline field definition shadows a built-in item field (e.g., redefining ``track_no``). Inline expressions now skip self-references during evaluation to avoid infinite recursion. :bug:`6115` - When hardlinking from a symlink (e.g. importing a symlink with hardlinking enabled), dereference the symlink then hardlink, rather than creating a new (potentially broken) symlink :bug:`5676` - :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API deprecation (HTTP 403 errors). When a 403 error is encountered from the audio-features endpoint, the plugin logs a warning once and skips audio features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. - Running ``beet --config config -e`` now edits ```` rather than the default config path. :bug:`5652` - :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only accepted a list of strings). :bug:`5962` - Fix a bug introduced in release 2.4.0 where import from any valid import-log-file always threw a "none of the paths are importable" error. - :doc:`/plugins/web`: repair broken ``/item/values/…`` and `/albums/values/…` endpoints. Previously, due to single-quotes (ie. string literal) in the SQL query, the query eg. ``GET /item/values/albumartist`` would return the literal "albumartist" instead of a list of unique album artists. - Sanitize log messages by removing control characters preventing terminal rendering issues. - When using :doc:`plugins/fromfilename` together with :doc:`plugins/edit`, temporary tags extracted from filenames are no longer lost when discarding or cancelling an edit session during import. :bug:`6104` - :ref:`update-cmd` :doc:`plugins/edit` fix display formatting of field changes to clearly show added and removed flexible fields. - :doc:`plugins/lastgenre`: Fix the issue where last.fm doesn't return any result in the artist genre stage because "concatenation" words in the artist name (like "feat.", "+", or "&") prevent it. Using the albumartists list field and fetching a genre for each artist separately improves the chance of receiving valid results in that stage. - :doc:`/plugins/ftintitle`: Fixed artist name splitting to prioritize explicit featuring tokens (feat, ft, featuring) over generic separators (&, and), preventing incorrect splits when both are present. - :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete all (old) metadata when new metadata is applied. :bug:`3706` - :doc:`/plugins/convert`: ``auto_keep`` now respects ``no_convert`` and ``never_convert_lossy_files`` when deciding whether to copy/transcode items, avoiding extra lossy duplicates. - :doc:`plugins/discogs`: Fixed unexpected flex attr from the Discogs plugin. :bug:`6177` - Errors in metadata plugins during autotage process will now be logged but won't crash beets anymore. If you want to raise exceptions instead, set the new configuration option ``raise_on_error`` to ``yes`` :bug:`5903`, :bug:`4789`. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - A new plugin event, ``album_matched``, is sent when an album that is being imported has been matched to its metadata and the corresponding distance has been calculated. - Added a reusable requests handler which can be used by plugins to make HTTP requests with built-in retry and backoff logic. It uses beets user-agent and configures timeouts. See :class:`~beetsplug._utils.requests.RequestHandler` for documentation. - Replaced dependency on ``python-musicbrainzngs`` with a lightweight custom MusicBrainz client implementation and updated relevant plugins accordingly: - :doc:`plugins/listenbrainz` - :doc:`plugins/mbcollection` - :doc:`plugins/mbpseudo` - :doc:`plugins/missing` - :doc:`plugins/musicbrainz` - :doc:`plugins/parentwork` See :class:`~beetsplug._utils.musicbrainz.MusicBrainzAPI` for documentation. For packagers ~~~~~~~~~~~~~ - The minimum supported Python version is now 3.10. - An unused dependency on ``mock`` has been removed. Other changes ~~~~~~~~~~~~~ - The documentation chapter :doc:`dev/paths` has been moved to the "For Developers" section and revised to reflect current best practices (pathlib usage). - Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into multiple modules within the ``beets/ui/commands`` directory for better maintainability. - :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is unavailable, enabling ``importorskip`` usage in pytest setup. - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. - Updated color documentation with ``bright_*`` and ``bg_bright_*`` entries. - Moved ``beets/random.py`` into ``beetsplug/random.py`` to cleanup core module. - dbcore: Allow models to declare SQL indices; add an ``items.album_id`` index to speed up ``album.items()`` queries. :bug:`5809` 2.5.1 (October 14, 2025) ------------------------ New features ~~~~~~~~~~~~ - :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to allow zeroing the disc number on write for single-disc albums. Defaults to False. Bug fixes ~~~~~~~~~ - |BeetsPlugin|: load the last plugin class defined in the plugin namespace. :bug:`6093` For packagers ~~~~~~~~~~~~~ - Fixed issue with legacy metadata plugins not copying properties from the base class. - Reverted the following: When installing ``beets`` via git or locally the version string now reflects the current git branch and commit hash. :bug:`6089` Other changes ~~~~~~~~~~~~~ - Removed outdated mailing list contact information from the documentation :bug:`5462`. - :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed sections and dropdown menus. Installation instructions have been streamlined, and a new subpage now provides additional setup details. - Documentation: introduced a new role ``conf`` for documenting configuration options. This role provides consistent formatting and creates references automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`, :doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation. 2.5.0 (October 11, 2025) ------------------------ New features ~~~~~~~~~~~~ - :doc:`plugins/lastgenre`: Add a ``--pretend`` option to preview genre changes without storing or writing them. - :doc:`plugins/convert`: Add a config option to disable writing metadata to converted files. - :doc:`plugins/discogs`: New config option :conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs numeric disambiguation on artist and label fields. - :doc:`plugins/discogs` Added support for featured artists. :bug:`6038` - :doc:`plugins/discogs` New configuration option :conf:`plugins.discogs:featured_string` to change the default string used to join featured artists. The default string is ``Feat.``. - :doc:`plugins/discogs` Support for ``artist_credit`` in Discogs tags. :bug:`3354` - :doc:`plugins/discogs` Support for name variations and config options to specify where the variations are written. :bug:`3354` - :doc:`plugins/web` Support for ``nexttrack`` keyboard press Bug fixes ~~~~~~~~~ - :doc:`plugins/musicbrainz` Refresh flexible MusicBrainz metadata on reimport so format changes are applied. :bug:`6036` - :doc:`plugins/spotify` Ensure ``spotifysync`` keeps popularity, ISRC, and related fields current even when audio features requests fail. :bug:`6061` - :doc:`plugins/spotify` Fixed an issue where track matching and lookups could return incorrect or misleading results when using the Spotify plugin. The problem occurred primarily when no album was provided or when the album field was an empty string. :bug:`5189` - :doc:`plugins/spotify` Removed old and undocumented config options ``artist_field``, ``album_field`` and ``track`` that were causing issues with track matching. :bug:`5189` - :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find matches due to query escaping (single vs double quotes). - :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from artists but not labels. :bug:`5366` - :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by an import of another |BeetsPlugin| class. :bug:`6033` - :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor regexps, allow for more cases, add some logging), add tests. - Metadata source plugins: Fixed data source penalty calculation that was incorrectly applied during import matching. The :conf:`plugins.index:source_weight` configuration option has been renamed to :conf:`plugins.index:data_source_mismatch_penalty` to better reflect its purpose. :bug:`6066` Other changes ~~~~~~~~~~~~~ - :doc:`plugins/index`: Clarify that musicbrainz must be mentioned if plugin list modified :bug:`6020` - :doc:`/faq`: Add check for musicbrainz plugin if auto-tagger can't find a match :bug:`6020` - :doc:`guides/tagger`: Section on no matching release found, related to possibly disabled musicbrainz plugin :bug:`6020` - Moved ``art.py`` utility module from ``beets`` into ``beetsplug`` namespace as it is not used in the core beets codebase. It can now be found in ``beetsplug._utils``. - Moved ``vfs.py`` utility module from ``beets`` into ``beetsplug`` namespace as it is not used in the core beets codebase. It can now be found in ``beetsplug._utils``. - :class:`beets.metadata_plugins.MetadataSourcePlugin`: Remove discogs specific disambiguation stripping. - When installing ``beets`` via git or locally the version string now reflects the current git branch and commit hash. :bug:`4448` - :ref:`match-config`: ``match.distance_weights.source`` configuration has been renamed to ``match.distance_weights.data_source`` for consistency with the name of the field it refers to. For developers and plugin authors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Typing improvements in ``beets/logging.py``: ``getLogger`` now returns ``BeetsLogger`` when called with a name, or ``RootLogger`` when called without a name. - The ``track_distance()`` and ``album_distance()`` methods have been removed from ``MetadataSourcePlugin``. Distance calculation for data source mismatches is now handled automatically by the core matching logic. This change simplifies the plugin architecture and fixes incorrect penalty calculations. :bug:`6066` - Metadata source plugins are now registered globally when instantiated, which makes their handling slightly more efficient. 2.4.0 (September 13, 2025) -------------------------- New features ~~~~~~~~~~~~ - :doc:`plugins/musicbrainz`: The MusicBrainz autotagger has been moved to a separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``, but if you've customized your ``plugins`` list in your configuration, you'll need to explicitly add ``musicbrainz`` to continue using this functionality. Configuration option :conf:`plugins.musicbrainz:enabled` has thus been deprecated. :bug:`2686` :bug:`4605` - :doc:`plugins/web`: Show notifications when a track plays. This uses the Media Session API to customize media notifications. - :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit` option to limit the number of results returned by the Discogs metadata search queries. - :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving singletons by their Discogs ID. :bug:`4661` - :doc:`plugins/replace`: Add new plugin. - :doc:`plugins/duplicates`: Add ``--remove`` option, allowing to remove from the library without deleting media files. :bug:`5832` - :doc:`plugins/playlist`: Support files with the ``.m3u8`` extension. :bug:`5829` - :doc:`plugins/mbcollection`: When getting the user collections, only consider collections of releases, and ignore collections of other entity types. - :doc:`plugins/mpdstats`: Add new configuration option, ``played_ratio_threshold``, to allow configuring the percentage the song must be played for it to be counted as played instead of skipped. - :doc:`plugins/web`: Display artist and album as part of the search results. - :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option :conf:`plugins.index:search_limit` to limit the number of results returned by search queries. Bug fixes ~~~~~~~~~ - :doc:`plugins/musicbrainz`: fix regression where user configured :conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788` - tests: Fix library tests failing on Windows when run from outside ``D:/``. :bug:`5802` - Fix an issue where calling ``Library.add`` would cause the ``database_change`` event to be sent twice, not once. :bug:`5560` - Fix ``HiddenFileTest`` by using ``bytestring_path()``. - tests: Fix tests failing without ``langdetect`` (by making it required). :bug:`5797` - :doc:`plugins/musicbrainz`: Fix the MusicBrainz search not taking into account the album/recording aliases - :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was ascii encoded. This resulted in bad matches for queries that contained special e.g. non latin characters as 盗作. If you want to keep the legacy behavior set the config option ``spotify.search_query_ascii: yes``. :bug:`5699` - :doc:`plugins/discogs`: Beets will no longer crash if a release has been deleted, and returns a 404. - :doc:`plugins/lastgenre`: Fix the issue introduced in Beets 2.3.0 where non-whitelisted last.fm genres were not canonicalized to parent genres. :bug:`5930` - :doc:`plugins/chroma`: AcoustID lookup HTTP requests will now time out after 10 seconds, rather than hanging the entire import process. - :doc:`/plugins/deezer`: Fix the issue with that every query to deezer was ascii encoded. This resulted in bad matches for queries that contained special e.g. non latin characters as 盗作. If you want to keep the legacy behavior set the config option ``deezer.search_query_ascii: yes``. :bug:`5860` - Fixed regression with :doc:`/plugins/listenbrainz` where the plugin could not be loaded :bug:`5975` - :doc:`/plugins/fromfilename`: Beets will no longer crash if a track's title field is missing. For packagers ~~~~~~~~~~~~~ - Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed from ``BeetsPlugin.candidates`` method signature since it is never passed in. If you override this method in your plugin, feel free to remove this parameter. - Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every python version. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - The ``fetchart`` plugins has seen a few changes to function signatures and source registration in the process of introducing typings to the code. Custom art sources might need to be adapted. - We split the responsibilities of plugins into two base classes 1. |BeetsPlugin| is the base class for all plugins, any plugin needs to inherit from this class. 2. :class:`beets.metadata_plugins.MetadataSourcePlugin` allows plugins to act like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in the beets repo are opted into this class where applicable. If you are maintaining a plugin that acts like a metadata source, i.e. you expose any of ``track_for_id``, ``album_for_id``, ``candidates``, ``item_candidates``, ``album_distance``, ``track_distance`` methods, please update your plugin to inherit from the new baseclass, as otherwise your plugin will stop working with the next major release. - Several definitions have been moved: - ``BLOB_TYPE`` constant, ``PathQuery`` and ``SingletonQuery`` queries have moved from ``beets.library`` to ``beets.dbcore.query`` module - ``DateType``, ``DurationType``, ``PathType`` types and ``MusicalKey`` class have moved from ``beets.library`` to ``beets.dbcore.types`` module. - ``Distance`` has moved from ``beets.autotag`` to ``beets.autotag.distance`` module. - ``beets.autotag.current_metadata`` has been renamed to ``beets.util.get_most_common_tags``. Old imports are now deprecated and will be removed in version ``3.0.0``. - ``beets.ui.decargs`` is deprecated and will be removed in version ``3.0.0``. - Beets is now PEP 561 compliant, which means that it provides type hints for all public APIs. This allows IDEs to provide better autocompletion and type checking for downstream users of the beets API. - ``plugins.find_plugins`` function does not anymore load plugins. You need to explicitly call ``plugins.load_plugins()`` to load them. - ``plugins.load_plugins`` function does not anymore accept the list of plugins to load. Instead, it loads all plugins that are configured by :ref:`plugins-config` configuration. - Flexible fields, which can be used by plugins to store additional metadata, now also support list values. Previously, beets would throw an error while storing the data in the SQL database due to missing type conversion. :bug:`5698` Other changes ~~~~~~~~~~~~~ - Refactor: Split responsibilities of Plugins into MetaDataPlugins and general Plugins. - Documentation structure for auto generated API references changed slightly. Autogenerated API references are now located in the ``docs/api`` subdirectory. - :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each case is shown on separate lines. - :doc:`/plugins/ftintitle`: Process items whose albumartist is not contained in the artist field, including compilations using Various Artists as an albumartist and album tracks by guest artists featuring a third artist. - Refactored library.py file by splitting it into multiple modules within the beets/library directory. - Added a test to check that all plugins can be imported without errors. - :doc:`/guides/main`: Add instructions to install beets on Void Linux. - :doc:`plugins/lastgenre`: Refactor loading whitelist and canonicalization file. :bug:`5979` - :doc:`plugins/lastgenre`: Updated and streamlined the genre whitelist and canonicalization tree :bug:`5977` - UI: Update default ``text_diff_added`` color from **bold red** to **bold green.** - UI: Use ``text_diff_added`` and ``text_diff_removed`` colors in **all** diff comparisons, including case differences. 2.3.1 (May 14, 2025) -------------------- Bug fixes ~~~~~~~~~ - :doc:`/reference/pathformat`: Fixed a regression where path legalization incorrectly removed parts of user-configured path formats that followed a dot (**.**). :bug:`5771` For packagers ~~~~~~~~~~~~~ - Force ``poetry`` version below 2 to avoid it mangling file modification times in ``sdist`` package. :bug:`5770` 2.3.0 (May 07, 2025) -------------------- Beets now requires Python 3.9 or later since support for EOL Python 3.8 has been dropped. New features ~~~~~~~~~~~~ - :doc:`plugins/lastgenre`: The new configuration option, ``keep_existing``, provides more fine-grained control over how pre-populated genre tags are handled. The ``force`` option now behaves in a more conventional manner. :bug:`4982` - :doc:`plugins/lyrics`: Add new configuration option ``dist_thresh`` to control the maximum allowed distance between the lyrics search result and the tagged item's artist and title. This is useful for preventing false positives when fetching lyrics. - :doc:`plugins/lyrics`: Rewrite lyrics translation functionality to use Azure AI Translator API and add relevant instructions to the documentation. - :doc:`plugins/missing`: Add support for all metadata sources. - :doc:`plugins/mbsync`: Add support for all metadata sorces. Bug fixes ~~~~~~~~~ - :doc:`plugins/thumbnails`: Fix API call to GIO on big endian architectures (like s390x) in thumbnails plugin. :bug:`5708` - :doc:`plugins/listenbrainz`: Fix rST formatting for URLs of Listenbrainz API Key documentation and config.yaml. - :doc:`plugins/listenbrainz`: Fix ``UnboundLocalError`` in cases where 'mbid' is not defined. - :doc:`plugins/fetchart`: Fix fetchart bug where a tempfile could not be deleted due to never being properly closed. :bug:`5521` - :doc:`plugins/lyrics`: LRCLib will fallback to plain lyrics if synced lyrics are not found and ``synced`` flag is set to ``yes``. - Synchronise files included in the source distribution with what we used to have before the introduction of Poetry. :bug:`5531` :bug:`5526` - :ref:`write-cmd`: Fix the issue where for certain files differences in ``mb_artistid``, ``mb_albumartistid`` and ``albumtype`` fields are shown on every attempt to write tags. Note: your music needs to be reimported with ``beet import -LI`` or synchronised with ``beet mbsync`` in order to fix this! :bug:`5265` :bug:`5371` :bug:`4715` - :ref:`import-cmd`: Fix ``MemoryError`` and improve performance tagging large albums by replacing ``munkres`` library with ``lap.lapjv``. :bug:`5207` - :ref:`query-sort`: Fix a bug that would raise an exception when sorting on a non-string field that is not populated in all items. :bug:`5512` - :doc:`plugins/lastgenre`: Fix track-level genre handling. Now when an album-level genre is set already, single tracks don't fall back to the album's genre and request their own last.fm genre. Also log messages regarding what's been tagged are now more polished. :bug:`5582` - Fix ambiguous column name ``sqlite3.OperationalError`` that occured in album queries that filtered album track titles, for example ``beet list -a keyword title:foo``. - :doc:`plugins/lyrics`: Rewrite lyrics tests using pytest to provide isolated configuration for each test case. This fixes the issue where some tests failed because they read developers' local lyrics configuration. :bug:`5133` - :doc:`plugins/lyrics`: Do not attempt to search for lyrics if either the artist or title is missing and ignore ``artist_sort`` value if it is empty. :bug:`2635` - :doc:`plugins/lyrics`: Fix fetching lyrics from ``lrclib`` source. If we cannot find lyrics for a specific album, artist, title combination, the plugin now tries to search for the artist and title and picks the most relevant result. Update the default ``sources`` configuration to prioritize ``lrclib`` over other sources since it returns reliable results quicker than others. :bug:`5102` - :doc:`plugins/lyrics`: Fix the issue with ``genius`` backend not being able to match lyrics when there is a slight variation in the artist name. :bug:`4791` - :doc:`plugins/lyrics`: Fix plugin crash when ``genius`` backend returns empty lyrics. :bug:`5583` - ImageMagick 7.1.1-44 is now supported. - :doc:`plugins/parentwork`: Only output parentwork changes when running in verbose mode. For packagers ~~~~~~~~~~~~~ - The minimum supported Python version is now 3.9. - External plugin developers: ``beetsplug/__init__.py`` file can be removed from your plugin as beets now uses native/implicit namespace package setup. Other changes ~~~~~~~~~~~~~ - Release workflow: fix the issue where the new release tag is created for the wrong (outdated) commit. Now the tag is created in the same workflow step right after committing the version update. :bug:`5539` - :doc:`/plugins/smartplaylist`: URL-encode additional item ``fields`` within generated EXTM3U playlists instead of JSON-encoding them. - typehints: ``./beets/importer.py`` file now has improved typehints. - typehints: ``./beets/plugins.py`` file now includes typehints. - :doc:`plugins/ftintitle`: Optimize the plugin by avoiding unnecessary writes to the database. - Database models are now serializable with pickle. 2.2.0 (December 02, 2024) ------------------------- New features ~~~~~~~~~~~~ - :doc:`/plugins/substitute`: Allow the replacement string to use capture groups from the match. It is thus possible to create more general rules, applying to many different artists at once. Bug fixes ~~~~~~~~~ - Check if running python from the Microsoft Store and provide feedback to install from python.org. :bug:`5467` - Fix bug where matcher doesn't consider medium number when importing. This makes it difficult to import hybrid SACDs and other releases with duplicate tracks. :bug:`5148` - Bring back test files and the manual to the source distribution tarball. :bug:`5513` Other changes ~~~~~~~~~~~~~ - Changed ``bitesize`` label to ``good first issue``. Our contribute_ page is now automatically populated with these issues. :bug:`4855` .. _contribute: https://github.com/beetbox/beets/contribute 2.1.0 (November 22, 2024) ------------------------- New features ~~~~~~~~~~~~ - New template function added: ``%capitalize``. Converts the first letter of the text to uppercase and the rest to lowercase. - Ability to query albums with track db fields and vice-versa, for example ``beet list -a title:something`` or ``beet list artpath:cover``. Consequently album queries involving ``path`` field have been sped up, like ``beet list -a path:/path/``. - :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which allows keeping the "feat." part in the artist metadata while still changing the title. - :doc:`plugins/autobpm`: Add new configuration option ``beat_track_kwargs`` which enables adjusting keyword arguments supplied to librosa's ``beat_track`` function call. - Beets now uses ``platformdirs`` to determine the default music directory. This location varies between systems -- for example, users can configure it on Unix systems via ``user-dirs.dirs(5)``. Bug fixes ~~~~~~~~~ - :doc:`plugins/ftintitle`: The detection of a "feat. X" part in a song title does not produce any false positives caused by words like "and" or "with" anymore. :bug:`5441` - :doc:`plugins/ftintitle`: The detection of a "feat. X" part now also matches such parts if they are in parentheses or brackets. :bug:`5436` - Improve naming of temporary files by separating the random part with the file extension. - Fix the ``auto`` value for the :ref:`reflink` config option. - Fix lyrics plugin only getting part of the lyrics from ``Genius.com`` :bug:`4815` - Album flexible fields are now correctly saved. For instance MusicBrainz external links such as ``bandcamp_album_id`` will be available on albums in addition to tracks. For albums already in your library, a re-import is required for the fields to be added. Such a re-import can be done with, in this case, ``beet import -L data_source:=MusicBrainz``. - :doc:`plugins/autobpm`: Fix the ``TypeError`` where tempo was being returned as a numpy array. Update ``librosa`` dependency constraint to prevent similar issues in the future. :bug:`5289` - :doc:`plugins/discogs`: Fix the ``TypeError`` when there is no description. - Use single quotes in all SQL queries :bug:`4709` - :doc:`plugins/lyrics`: Update ``tekstowo`` backend to fetch lyrics directly since recent updates to their website made it unsearchable. :bug:`5456` - :doc:`plugins/convert`: Fixed the convert plugin ``no_convert`` option so that it no longer treats "and" and "or" queries the same. To maintain previous behaviour add commas between your query keywords. For help see :ref:`combiningqueries`. - Fix the ``TypeError`` when :ref:`set_fields` is provided non-string values. :bug:`4840` For packagers ~~~~~~~~~~~~~ - The minimum supported Python version is now 3.8. - The ``beet`` script has been removed from the repository. - The ``typing_extensions`` is required for Python 3.10 and below. Other changes ~~~~~~~~~~~~~ - :doc:`contributing`: The project now uses ``poetry`` for packaging and dependency management. This change affects project management and mostly affects beets developers. Please see updates in :ref:`getting-the-source` and :ref:`testing` for more information. - :doc:`contributing`: Since ``poetry`` now manages local virtual environments, ``tox`` has been replaced by a task runner ``poethepoet``. This change affects beets developers and contributors. Please see updates in the :ref:`development-tools` section for more details. Type ``poe`` while in the project directory to see the available commands. - Installation instructions have been made consistent across plugins documentation. Users should simply install ``beets`` with an ``extra`` of the corresponding plugin name in order to install extra dependencies for that plugin. - GitHub workflows have been reorganised for clarity: style, linting, type and docs checks now live in separate jobs and are named accordingly. - Added caching for dependency installation in all CI jobs which speeds them up a bit, especially the tests. - The linting workflow has been made to run only when Python files or documentation is changed, and they only check the changed files. When dependencies are updated (``poetry.lock``), then the entire code base is checked. - The long-deprecated ``beets.util.confit`` module has been removed. This may cause extremely outdated external plugins to fail to load. - :doc:`plugins/autobpm`: Add plugin dependencies to ``pyproject.toml`` under the ``autobpm`` extra and update the plugin installation instructions in the docs. Since importing the bpm calculation functionality from ``librosa`` takes around 4 seconds, update the plugin to only do so when it actually needs to calculate the bpm. Previously this import was being done immediately, so every ``beet`` invocation was being delayed by a couple of seconds. :bug:`5185` 2.0.0 (May 30, 2024) -------------------- With this release, beets now requires Python 3.7 or later (it removes support for Python 3.6). Major new features ~~~~~~~~~~~~~~~~~~ - The beets importer UI received a major overhaul. Several new configuration options are available for customizing layout and colors: :ref:`ui_options`. :bug:`3721` :bug:`5028` New features ~~~~~~~~~~~~ - :doc:`/plugins/edit`: Prefer editor from ``VISUAL`` environment variable over ``EDITOR``. - :ref:`config-cmd`: Prefer editor from ``VISUAL`` environment variable over ``EDITOR``. - :doc:`/plugins/listenbrainz`: Add initial support for importing history and playlists from ``ListenBrainz`` :bug:`1719` - :doc:`plugins/mbsubmit`: add new prompt choices helping further to submit unmatched tracks to MusicBrainz faster. - :doc:`plugins/spotify`: We now fetch track's ISRC, EAN, and UPC identifiers from Spotify when using the ``spotifysync`` command. :bug:`4992` - :doc:`plugins/discogs`: supply a value for the ``cover_art_url`` attribute, for use by ``fetchart``. :bug:`429` - :ref:`update-cmd`: added ``-e`` flag for excluding fields from being updated. - :doc:`/plugins/deezer`: Import rank and other attributes from Deezer during import and add a function to update the rank of existing items. :bug:`4841` - resolve transl-tracklisting relations for pseudo releases and merge data with the actual release :bug:`654` - Fetchart: Use the right field (``spotify_album_id``) to obtain the Spotify album id :bug:`4803` - Prevent reimporting album if it is permanently removed from Spotify :bug:`4800` - Added option to use ``cover_art_url`` as an album art source in the ``fetchart`` plugin. :bug:`4707` - :doc:`/plugins/fetchart`: The plugin can now get album art from ``spotify``. - Added option to specify a URL in the ``embedart`` plugin. :bug:`83` - :ref:`list-cmd` ``singleton:true`` queries have been made faster - :ref:`list-cmd` ``singleton:1`` and ``singleton:0`` can now alternatively be used in queries, same as ``comp`` - --from-logfile now parses log files using a UTF-8 encoding in ``beets/beets/ui/commands.py``. :bug:`4693` - :doc:`/plugins/bareasc` lookups have been made faster - :ref:`list-cmd` lookups using the pattern operator ``::`` have been made faster - Added additional error handling for ``spotify`` plugin. :bug:`4686` - We now import the remixer field from Musicbrainz into the library. :bug:`4428` - :doc:`/plugins/mbsubmit`: Added a new ``mbsubmit`` command to print track information to be submitted to MusicBrainz after initial import. :bug:`4455` - Added ``spotify_updated`` field to track when the information was last updated. - We now import and tag the ``album`` information when importing singletons using Spotify source. :bug:`4398` - :doc:`/plugins/spotify`: The plugin now provides an additional command ``spotifysync`` that allows getting track popularity and audio features information from Spotify. :bug:`4094` - :doc:`/plugins/spotify`: The plugin now records Spotify-specific IDs in the ``spotify_album_id``, ``spotify_artist_id``, and ``spotify_track_id`` fields. :bug:`4348` - Create the parental directories for database if they do not exist. :bug:`3808` :bug:`4327` - :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option allows disabling the MusicBrainz metadata source during the autotagging process - :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101` - Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. - Add query prefixes ``=`` and ``~``. - A new configuration option, :ref:`duplicate_keys`, lets you change which fields the beets importer uses to identify duplicates. :bug:`1133` :bug:`4199` - Add :ref:`exact match ` queries, using the prefixes ``=`` and ``=~``. :bug:`4251` - :doc:`/plugins/discogs`: Permit appending style to genre. - :doc:`plugins/discogs`: Implement item_candidates for matching singletons. - :doc:`plugins/discogs`: Check for compliant discogs_client module. - :doc:`/plugins/convert`: Add a new ``auto_keep`` option that automatically converts files but keeps the *originals* in the library. :bug:`1840` :bug:`4302` - Added a ``-P`` (or ``--disable-plugins``) flag to specify one/multiple plugin(s) to be disabled at startup. - :ref:`import-options`: Add support for re-running the importer on paths in log files that were created with the ``-l`` (or ``--logfile``) argument. :bug:`4379` :bug:`4387` - Preserve mtimes from archives :bug:`4392` - Add :ref:`%sunique{} ` template to disambiguate between singletons. :bug:`4438` - Add a new ``import.ignored_alias_types`` config option to allow for specific alias types to be skipped over when importing items/albums. - :doc:`/plugins/smartplaylist`: A new ``--pretend`` option lets the user see what a new or changed smart playlist saved in the config is actually returning. :bug:`4573` - :doc:`/plugins/fromfilename`: Add debug log messages that inform when the plugin replaced bad (missing) artist, title or tracknumber metadata. :bug:`4561` :bug:`4600` - :ref:`musicbrainz-config`: MusicBrainz release pages often link to related metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's will be extracted from those URL's and imported to the library. :bug:`4220` - :doc:`/plugins/convert`: Add support for generating m3u8 playlists together with converted media files. :bug:`4373` - Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809` - :doc:`plugins/discogs`: Add support for applying album information on singleton imports. :bug:`4716` - :doc:`/plugins/smartplaylist`: During explicit runs of the ``splupdate`` command, the log message "Creating playlist ..."" is now displayed instead of hidden in the debug log, which states some form of progress through the UI. :bug:`4861` - :doc:`plugins/subsonicupdate`: Updates are now triggered whenever either the beets database is changed or a smart playlist is created/updated. :bug:`4862` - :doc:`plugins/importfeeds`: Add a new output format allowing to save a playlist once per import session. :bug:`4863` - Make ArtResizer work with :pypi:`PIL`/:pypi:`pillow` 10.0.0 removals. :bug:`4869` - A new configuration option, :ref:`duplicate_verbose_prompt`, allows changing how duplicates are presented during import. :bug:`4866` - :doc:`/plugins/embyupdate`: Add handling for private users by adding ``userid`` config option. :bug:`4402` - :doc:`/plugins/substitute`: Add the new plugin ``substitute`` as an alternative to the ``rewrite`` plugin. The main difference between them being that ``rewrite`` modifies files' metadata and ``substitute`` does not. :bug:`2786` - Add support for ``artists`` and ``albumartists`` multi-valued tags. :bug:`505` - :doc:`/plugins/autobpm`: Add the ``autobpm`` plugin which uses Librosa to calculate the BPM of the audio. :bug:`3856` - :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where the ``maxwidth`` option would not be used to download a pre-sized thumbnail for release groups, as is already done with releases. - :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where no cover would be found when the ``maxwidth`` option matches a pre-sized thumbnail size, but no thumbnail is provided by CAA. We now fallback to the raw image. - :doc:`/plugins/advancedrewrite`: Add an advanced version of the ``rewrite`` plugin which allows to replace fields based on a given library query. - :doc:`/plugins/lyrics`: Add LRCLIB as a new lyrics provider and a new ``synced`` option to prefer synced lyrics over plain lyrics. - :ref:`import-cmd`: Expose import.quiet_fallback as CLI option. - :ref:`import-cmd`: Expose ``import.incremental_skip_later`` as CLI option. - :doc:`/plugins/smartplaylist`: Expose config options as CLI options. - :doc:`/plugins/smartplaylist`: Add new option ``smartplaylist.output``. - :doc:`/plugins/smartplaylist`: Add new option ``smartplaylist.uri_format``. - Sorted the default configuration file into categories. :bug:`4987` - :doc:`/plugins/convert`: Don't treat WAVE (``.wav``) files as lossy anymore when using the ``never_convert_lossy_files`` option. They will get transcoded like the other lossless formats. - Add support for ``barcode`` field. :bug:`3172` - :doc:`/plugins/smartplaylist`: Add new config option ``smartplaylist.fields``. - :doc:`/plugins/fetchart`: Defer source removal config option evaluation to the point where they are used really, supporting temporary config changes. Bug fixes ~~~~~~~~~ - Improve ListenBrainz error handling. :bug:`5459` - :doc:`/plugins/deezer`: Improve requests error handling. - :doc:`/plugins/lastimport`: Improve error handling in the ``process_tracks`` function and enable it to be used with other plugins. - :doc:`/plugins/spotify`: Improve handling of ConnectionError. - :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds. :bug:`4983` - :doc:`/plugins/spotify`: Add bad gateway (502) error handling. - :doc:`/plugins/spotify`: Add a limit of 3 retries, instead of retrying endlessly when the API is not available. - Fix a crash when the Spotify API timeouts or does not return a ``Retry-After`` interval. :bug:`4942` - :doc:`/plugins/scrub`: Fixed the import behavior where scrubbed database tags were restored to newly imported tracks with config settings ``scrub.auto: yes`` and ``import.write: no``. :bug:`4326` - :doc:`/plugins/deezer`: Fixed the error where Deezer plugin would crash if non-Deezer id is passed during import. - :doc:`/plugins/fetchart`: Fix fetching from Cover Art Archive when the ``maxwidth`` option is set to one of the supported Cover Art Archive widths. - :doc:`/plugins/discogs`: Fix "Discogs plugin replacing Feat. or Ft. with a comma" by fixing an oversight that removed a functionality from the code base when the MetadataSourcePlugin abstract class was introduced in PR's #3335 and #3371. :bug:`4401` - :doc:`/plugins/convert`: Set default ``max_bitrate`` value to ``None`` to avoid transcoding when this parameter is not set. :bug:`4472` - :doc:`/plugins/replaygain`: Avoid a crash when errors occur in the analysis backend. :bug:`4506` - We now use Python's defaults for command-line argument encoding, which should reduce the chance for errors and "file not found" failures when invoking other command-line tools, especially on Windows. :bug:`4507` - We now respect the Spotify API's rate limiting, which avoids crashing when the API reports code 429 (too many requests). :bug:`4370` - Fix implicit paths OR queries (e.g. ``beet list /path/ , /other-path/``) which have previously been returning the entire library. :bug:`1865` - The Discogs release ID is now populated correctly to the discogs_albumid field again (it was no longer working after Discogs changed their release URL format). :bug:`4225` - The autotagger no longer considers all matches without a MusicBrainz ID as duplicates of each other. :bug:`4299` - :doc:`/plugins/convert`: Resize album art when embedding :bug:`2116` - :doc:`/plugins/deezer`: Fix auto tagger pagination issues (fetch beyond the first 25 tracks of a release). - :doc:`/plugins/spotify`: Fix auto tagger pagination issues (fetch beyond the first 50 tracks of a release). - :doc:`/plugins/lyrics`: Fix Genius search by using query params instead of body. - :doc:`/plugins/unimported`: The new ``ignore_subdirectories`` configuration option added in 1.6.0 now has a default value if it hasn't been set. - :doc:`/plugins/deezer`: Tolerate missing fields when searching for singleton tracks. :bug:`4116` - :doc:`/plugins/replaygain`: The type of the internal ``r128_track_gain`` and ``r128_album_gain`` fields was changed from integer to float to fix loss of precision due to truncation. :bug:`4169` - Fix a regression in the previous release that caused a ``TypeError`` when moving files across filesystems. :bug:`4168` - :doc:`/plugins/convert`: Deleting the original files during conversion no longer logs output when the ``quiet`` flag is enabled. - :doc:`plugins/web`: Fix handling of "query" requests. Previously queries consisting of more than one token (separated by a slash) always returned an empty result. - :doc:`/plugins/discogs`: Skip Discogs query on insufficiently tagged files (artist and album tags missing) to prevent arbitrary candidate results. :bug:`4227` - :doc:`plugins/lyrics`: Fixed issues with the Tekstowo.pl and Genius backends where some non-lyrics content got included in the lyrics - :doc:`plugins/limit`: Better header formatting to improve index - :doc:`plugins/replaygain`: Correctly handle the ``overwrite`` config option, which forces recomputing ReplayGain values on import even for tracks that already have the tags. - :doc:`plugins/embedart`: Fix a crash when using recent versions of ImageMagick and the ``compare_threshold`` option. :bug:`4272` - :doc:`plugins/lyrics`: Fixed issue with Genius header being included in lyrics, added test case of up-to-date Genius html - :doc:`plugins/importadded`: Fix a bug with recently added reflink import option that causes a crash when ImportAdded plugin enabled. :bug:`4389` - :doc:`plugins/convert`: Fix a bug with the ``wma`` format alias. - :doc:`/plugins/web`: Fix get file from item. - :doc:`/plugins/lastgenre`: Fix a duplicated entry for trip hop in the default genre list. :bug:`4510` - :doc:`plugins/lyrics`: Fixed issue with Tekstowo backend not actually checking if the found song matches. :bug:`4406` - :doc:`plugins/embedart`: Add support for ImageMagick 7.1.1-12 :bug:`4836` - :doc:`/plugins/fromfilename`: Fix failed detection of filename patterns. :bug:`4561` :bug:`4600` - Fix issue where deletion of flexible fields on an album doesn't cascade to items :bug:`4662` - Fix issue where ``beet write`` continuously retags the ``albumtypes`` metadata field in files. Additionally broken data could have been added to the library when the tag was read from file back into the library using ``beet update``. It is required for all users to **check if such broken data is present in the library**. Following the instructions `described here <https://github.com/beetbox/beets/pull/4582#issuecomment-1445023493>`_, a sanity check and potential fix is easily possible. :bug:`4528` - Fix updating "data_source" on re-imports and improve logging when flexible attributes are being re-imported. :bug:`4726` - :doc:`/plugins/fetchart`: Correctly select the cover art from fanart.tv with the highest number of likes - :doc:`/plugins/lyrics`: Fix a crash with the Google backend when processing some web pages. :bug:`4875` - Modifying flexible attributes of albums now cascade to the individual album tracks, similar to how fixed album attributes have been cascading to tracks already. A new option ``--noinherit/-I`` to :ref:`modify <modify-cmd>` allows changing this behaviour. :bug:`4822` - Fix bug where an interrupted import process poisons the database, causing a null path that can't be removed. :bug:`4906` - :doc:`/plugins/discogs`: Fix bug where empty artist and title fields would return None instead of an empty list. :bug:`4973` - Fix bug regarding displaying tracks that have been changed not being displayed unless the detail configuration is enabled. - :doc:`/plugins/web`: Fix range request support, allowing to play large audio/ opus files using e.g. a browser/firefox or gstreamer/mopidy directly. - Fix bug where ``zsh`` completion script made assumptions about the specific variant of ``awk`` installed and required specific settings for ``sqlite3`` and caching in ``zsh``. :bug:`3546` - Remove unused functions :bug:`5103` - Fix bug where all media types are reported as the first media type when importing with MusicBrainz as the data source :bug:`4947` - Fix bug where unimported plugin would not ignore children directories of ignored directories. :bug:`5130` - Fix bug where some plugin commands hang indefinitely due to a missing ``requests`` timeout. - Fix cover art resizing logic to support multiple steps of resizing :bug:`5151` - :doc:`/plugins/convert`: Fix attempt to convert and perform side-effects if library file is not readable. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - beets now explicitly prevents multiple plugins to define replacement functions for the same field. When previously defining ``template_fields`` for the same field in two plugins, the last loaded plugin would silently overwrite the function defined by the other plugin. Now, beets will raise an exception when this happens. :bug:`5002` - Allow reuse of some parts of beets' testing components. This may ease the work for externally developed plugins or related software (e.g. the beets plugin for Mopidy), if they need to create an in-memory instance of a beets music library for their tests. For packagers ~~~~~~~~~~~~~ - As noted above, the minimum Python version is now 3.7. - We fixed a version for the dependency on the Confuse_ library. :bug:`4167` - The minimum required version of :pypi:`mediafile` is now 0.9.0. Other changes ~~~~~~~~~~~~~ - Add ``sphinx`` and ``sphinx_rtd_theme`` as dependencies for a new ``docs`` extra :bug:`4643` - :doc:`/plugins/absubmit`: Deprecate the ``absubmit`` plugin since AcousticBrainz has stopped accepting new submissions. :bug:`4627` - :doc:`/plugins/acousticbrainz`: Deprecate the ``acousticbrainz`` plugin since the AcousticBrainz project has shut down. :bug:`4627` - :doc:`/plugins/limit`: Limit query results to head or tail (``lslimit`` command only) - :doc:`/plugins/fish`: Add ``--output`` option. - :doc:`/plugins/lyrics`: Remove Musixmatch from default enabled sources as they are currently blocking requests from the beets user agent. :bug:`4585` - :doc:`/faq`: :ref:`multidisc`: Elaborated the multi-disc FAQ :bug:`4806` - :doc:`/faq`: :ref:`src`: Removed some long lines. - Refactor the test cases to avoid test smells. 1.6.0 (November 27, 2021) ------------------------- This release is our first experiment with time-based releases! We are aiming to publish a new release of beets every 3 months. We therefore have a healthy but not dizzyingly long list of new features and fixes. With this release, beets now requires Python 3.6 or later (it removes support for Python 2.7, 3.4, and 3.5). There are also a few other dependency changes---if you're a maintainer of a beets package for a package manager, thank you for your ongoing efforts, and please see the list of notes below. Major new features ~~~~~~~~~~~~~~~~~~ - When fetching genres from MusicBrainz, we now include genres from the release group (in addition to the release). We also prioritize genres based on the number of votes. Thanks to :user:`aereaux`. - Primary and secondary release types from MusicBrainz are now stored in a new ``albumtypes`` field. Thanks to :user:`edgars-supe`. :bug:`2200` - An accompanying new :doc:`/plugins/albumtypes` includes some options for formatting this new ``albumtypes`` field. Thanks to :user:`edgars-supe`. - The :ref:`modify-cmd` and :ref:`import-cmd` can now use :doc:`/reference/pathformat` formats when setting fields. For example, you can now do ``beet modify title='$track $title'`` to put track numbers into songs' titles. :bug:`488` Other new things ~~~~~~~~~~~~~~~~ - :doc:`/plugins/permissions`: The plugin now sets cover art permissions to match the audio file permissions. - :doc:`/plugins/unimported`: A new configuration option supports excluding specific subdirectories in library. - :doc:`/plugins/info`: Add support for an ``--album`` flag. - :doc:`/plugins/export`: Similarly add support for an ``--album`` flag. - ``beet move`` now highlights path differences in color (when enabled). - When moving files and a direct rename of a file is not possible (for example, when crossing filesystems), beets now copies to a temporary file in the target folder first and then moves to the destination instead of directly copying the target path. This gets us closer to always updating files atomically. Thanks to :user:`catap`. :bug:`4060` - :doc:`/plugins/fetchart`: Add a new option to store cover art as non-progressive image. This is useful for DAPs that do not support progressive images. Set ``deinterlace: yes`` in your configuration to enable this conversion. - :doc:`/plugins/fetchart`: Add a new option to change the file format of cover art images. This may also be useful for DAPs that only support some image formats. - Support flexible attributes in ``%aunique``. :bug:`2678` :bug:`3553` - Make ``%aunique`` faster, especially when using inline fields. :bug:`4145` Bug fixes ~~~~~~~~~ - :doc:`/plugins/lyrics`: Fix a crash when Beautiful Soup is not installed. :bug:`4027` - :doc:`/plugins/discogs`: Support a new Discogs URL format for IDs. :bug:`4080` - :doc:`/plugins/discogs`: Remove built-in rate-limiting because the Discogs Python library we use now has its own rate-limiting. :bug:`4108` - :doc:`/plugins/export`: Fix some duplicated output. - :doc:`/plugins/aura`: Fix a potential security hole when serving image files. :bug:`4160` For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - :py:meth:`beets.library.Item.destination` now accepts a ``replacements`` argument to be used in favor of the default. - The ``pluginload`` event is now sent after plugin types and queries are available, not before. - A new plugin event, ``album_removed``, is called when an album is removed from the library (even when its file is not deleted from disk). Here are some notes for packagers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - As noted above, the minimum Python version is now 3.6. - We fixed a flaky test, named ``test_album_art`` in the ``test_zero.py`` file, that some distributions had disabled. Disabling this test should no longer be necessary. :bug:`4037` :bug:`4038` - This version of beets no longer depends on the six_ library. :bug:`4030` - The ``gmusic`` plugin was removed since Google Play Music has been shut down. Thus, the optional dependency on ``gmusicapi`` does not exist anymore. :bug:`4089` 1.5.0 (August 19, 2021) ----------------------- This long overdue release of beets includes far too many exciting and useful features than could ever be satisfactorily enumerated. As a technical detail, it also introduces two new external libraries: MediaFile_ and Confuse_ used to be part of beets but are now reusable dependencies---packagers, please take note. Finally, this is the last version of beets where we intend to support Python 2.x and 3.5; future releases will soon require Python 3.6. One non-technical change is that we moved our official ``#beets`` home on IRC from freenode to Libera.Chat_. .. _libera.chat: https://libera.chat/ Major new features ~~~~~~~~~~~~~~~~~~ - Fields in queries now fall back to an item's album and check its fields too. Notably, this allows querying items by an album's attribute: in other words, ``beet list foo:bar`` will not only find tracks with the ``foo`` attribute; it will also find tracks *on albums* that have the ``foo`` attribute. This may be particularly useful in the :ref:`path-format-config`, which matches individual items to decide which path to use. Thanks to :user:`FichteFoll`. :bug:`2797` :bug:`2988` - A new :ref:`reflink` config option instructs the importer to create fast, copy-on-write file clones on filesystems that support them. Thanks to :user:`rubdos`. - A new :doc:`/plugins/unimported` lets you find untracked files in your library directory. - The :doc:`/plugins/aura` has arrived! Try out the future of remote music library access today. - We now fetch information about works_ from MusicBrainz. MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid`` (the MBID), and ``work_disambig`` (the disambiguation string). Thanks to :user:`dosoe`. :bug:`2580` :bug:`3272` - A new :doc:`/plugins/parentwork` gets information about the original work, which is useful for classical music. Thanks to :user:`dosoe`. :bug:`2580` :bug:`3279` - :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16 of the MPD protocol. This is enough to get it talking to more complicated clients like ncmpcpp, but there are still some incompatibilities, largely due to MPD commands we don't support yet. (Let us know if you find an MPD client that doesn't get along with BPD!) :bug:`3214` :bug:`800` - A new :doc:`/plugins/deezer` can autotag tracks and albums using the Deezer_ database. Thanks to :user:`rhlahuja`. :bug:`3355` - A new :doc:`/plugins/bareasc` provides a new query type: "bare ASCII" queries that ignore accented characters, treating them as though they were plain ASCII characters. Use the ``#`` prefix with :ref:`list-cmd` or other commands. :bug:`3882` - :doc:`/plugins/fetchart`: The plugin can now get album art from last.fm_. :bug:`3530` - :doc:`/plugins/web`: The API now supports the HTTP ``DELETE`` and ``PATCH`` methods for modifying items. They are disabled by default; set ``readonly: no`` in your configuration file to enable modification via the API. :bug:`3870` Other new things ~~~~~~~~~~~~~~~~ - ``beet remove`` now also allows interactive selection of items from the query, similar to ``beet modify``. - Enable HTTPS for MusicBrainz by default and add configuration option :conf:`plugins.musicbrainz:https` for custom servers. See :ref:`musicbrainz-config` for more details. - :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the right local path from MPD information. - :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on Python 3. - :doc:`/plugins/lastgenre`: Add a new ``title_case`` config option to make title-case formatting optional. - There's a new message when running ``beet config`` when there's no available configuration file. :bug:`3779` - When importing a duplicate album, the prompt now says "keep all" instead of "keep both" to reflect that there may be more than two albums involved. :bug:`3569` - :doc:`/plugins/chroma`: The plugin now updates file metadata after generating fingerprints through the ``submit`` command. - :doc:`/plugins/lastgenre`: Added more heavy metal genres to the built-in genre filter lists. - A new :doc:`/plugins/subsonicplaylist` can import playlists from a Subsonic server. - :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between token- and password-based authentication based on the server version. - A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use more metadata in MusicBrainz queries to further narrow the search. - A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets. - :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality`` option that controls the quality of the image output when the image is resized. - :doc:`plugins/keyfinder`: Added support for keyfinder-cli_. Thanks to :user:`BrainDamage`. - :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to allow downloading of higher resolution iTunes artwork (at the expense of file size). :bug:`3391` - :doc:`plugins/discogs`: The plugin applies two new fields: ``discogs_labelid`` and ``discogs_artistid``. :bug:`3413` - :doc:`/plugins/export`: Added a new ``-f`` (``--format``) flag, which can export your data as JSON, JSON lines, CSV, or XML. Thanks to :user:`austinmm`. :bug:`3402` - :doc:`/plugins/convert`: Added a new ``-l`` (``--link``) flag and ``link`` option as well as the ``-H`` (``--hardlink``) flag and ``hardlink`` option, which symlink or hardlink files that do not need to be converted (instead of copying them). :bug:`2324` - :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option that enables calculation of album ReplayGain on disc level instead of album level. Thanks to :user:`samuelnilsson`. :bug:`293` - :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports ``R128_`` tags. :bug:`3056` - :doc:`plugins/replaygain`: A new ``r128_targetlevel`` configuration option defines the reference volume for files using ``R128_`` tags. ``targetlevel`` only configures the reference volume for ``REPLAYGAIN_`` files. :bug:`3065` - :doc:`/plugins/discogs`: The plugin now collects the "style" field. Thanks to :user:`thedevilisinthedetails`. :bug:`2579` :bug:`3251` - :doc:`/plugins/absubmit`: By default, the plugin now avoids re-analyzing files that already have AcousticBrainz data. There are new ``force`` and ``pretend`` options to help control this new behavior. Thanks to :user:`SusannaMaria`. :bug:`3318` - :doc:`/plugins/discogs`: The plugin now also gets genre information and a new ``discogs_albumid`` field from the Discogs API. Thanks to :user:`thedevilisinthedetails`. :bug:`465` :bug:`3322` - :doc:`/plugins/acousticbrainz`: The plugin now fetches two more additional fields: ``moods_mirex`` and ``timbre``. Thanks to :user:`malcops`. :bug:`2860` - :doc:`/plugins/playlist` and :doc:`/plugins/smartplaylist`: A new ``forward_slash`` config option facilitates compatibility with MPD on Windows. Thanks to :user:`MartyLake`. :bug:`3331` :bug:`3334` - The ``data_source`` field, which indicates which metadata source was used during an autotagging import, is now also applied as an album-level flexible attribute. :bug:`3350` :bug:`1693` - :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM, and genre for each track. :bug:`2080` - A new :doc:`/plugins/bpsync` can synchronize metadata changes from the Beatport database (like the existing :doc:`/plugins/mbsync` for MusicBrainz). - :doc:`/plugins/hook`: The plugin now treats non-zero exit codes as errors. :bug:`3409` - :doc:`/plugins/subsonicupdate`: A new ``url`` configuration replaces the older (and now deprecated) separate ``host``, ``port``, and ``contextpath`` config options. As a consequence, the plugin can now talk to Subsonic over HTTPS. Thanks to :user:`jef`. :bug:`3449` - :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option enables incorporation of work names and intra-work divisions into imported track titles. Thanks to :user:`cole-miller`. :bug:`3459` - :doc:`/plugins/web`: The query API now interprets backslashes as path separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567` - ``beet import`` now handles tar archives with bzip2 or gzip compression. :bug:`3606` - ``beet import`` *also* now handles 7z archives, via the py7zr_ library. Thanks to :user:`arogl`. :bug:`3906` - :doc:`/plugins/plexupdate`: Added an option to use a secure connection to Plex server, and to ignore certificate validation errors if necessary. :bug:`2871` - :doc:`/plugins/convert`: A new ``delete_originals`` configuration option can delete the source files after conversion during import. Thanks to :user:`logan-arens`. :bug:`2947` - There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins to load. - A new :conf:`plugins.musicbrainz:genres` option fetches genre information from MusicBrainz. This functionality depends on functionality that is currently unreleased in the python-musicbrainzngs_ library: see PR `#266 <https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to :user:`aereaux`. - :doc:`/plugins/replaygain`: Analysis now happens in parallel using the ``command`` and ``ffmpeg`` backends. :bug:`3478` - :doc:`plugins/replaygain`: The bs1770gain backend is removed. Thanks to :user:`SamuelCook`. - Added ``trackdisambig`` which stores the recording disambiguation from MusicBrainz for each track. :bug:`1904` - :doc:`plugins/fetchart`: The new ``max_filesize`` configuration sets a maximum target image file size. - :doc:`/plugins/badfiles`: Checkers can now run during import with the ``check_on_import`` config option. - :doc:`/plugins/export`: The plugin is now much faster when using the ``--include-keys`` option is used. Thanks to :user:`ssssam`. - The importer's :ref:`set_fields` option now saves all updated fields to on-disk metadata. :bug:`3925` :bug:`3927` - We now fetch ISRC identifiers from MusicBrainz. Thanks to :user:`aereaux`. - :doc:`/plugins/metasync`: The plugin now also fetches the "Date Added" field from iTunes databases and stores it in the ``itunes_dateadded`` field. Thanks to :user:`sandersantema`. - :doc:`/plugins/lyrics`: Added a new Tekstowo.pl lyrics provider. Thanks to various people for the implementation and for reporting issues with the initial version. :bug:`3344` :bug:`3904` :bug:`3905` :bug:`3994` - ``beet update`` will now confirm that the user still wants to update if their library folder cannot be found, preventing the user from accidentally wiping out their beets database. Thanks to user: ``logan-arens``. :bug:`1934` Fixes ~~~~~ - Adapt to breaking changes in Python's ``ast`` module in Python 3.8. - :doc:`/plugins/beatport`: Fix the assignment of the ``genre`` field, and rename ``musical_key`` to ``initial_key``. :bug:`3387` - :doc:`/plugins/lyrics`: Fixed the Musixmatch backend for lyrics pages when lyrics are divided into multiple elements on the webpage, and when the lyrics are missing. - :doc:`/plugins/web`: Allow use of the backslash character in regex queries. :bug:`3867` - :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be redacted even when ``include_paths`` option is set. :bug:`3866` - :doc:`/plugins/discogs`: Fixed a bug with the :conf:`plugins.discogs:index_tracks` option that sometimes caused the index to be discarded. Also, remove the extra semicolon that was added when there is no index track. - :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method rather the ``GET`` method. Also includes better exception handling, response parsing, and tests. - :doc:`/plugins/the`: Fixed incorrect regex for "the" that matched any 3-letter combination of the letters t, h, e. :bug:`3701` - :doc:`/plugins/fetchart`: Fixed a bug that caused the plugin to not take environment variables, such as proxy servers, into account when making requests. :bug:`3450` - :doc:`/plugins/fetchart`: Temporary files for fetched album art that fail validation are now removed. - :doc:`/plugins/inline`: In function-style field definitions that refer to flexible attributes, values could stick around from one function invocation to the next. This meant that, when displaying a list of objects, later objects could seem to reuse values from earlier objects when they were missing a value for a given field. These values are now properly undefined. :bug:`2406` - :doc:`/plugins/bpd`: Seeking by fractions of a second now works as intended, fixing crashes in MPD clients like mpDris2 on seek. The ``playlistid`` command now works properly in its zero-argument form. :bug:`3214` - :doc:`/plugins/replaygain`: Fix a Python 3 incompatibility in the Python Audio Tools backend. :bug:`3305` - :doc:`/plugins/importadded`: Fixed a crash that occurred when the ``after_write`` signal was emitted. :bug:`3301` - :doc:`plugins/replaygain`: Fix the storage format for R128 gain tags. :bug:`3311` :bug:`3314` - :doc:`/plugins/discogs`: Fixed a crash that occurred when the master URI isn't set in the API response. :bug:`2965` :bug:`3239` - :doc:`/plugins/spotify`: Fix handling of year-only release dates returned by the Spotify albums API. Thanks to :user:`rhlahuja`. :bug:`3343` - Fixed a bug that caused the UI to display incorrect track numbers for tracks with index 0 when the ``per_disc_numbering`` option was set. :bug:`3346` - ``none_rec_action`` does not import automatically when ``timid`` is enabled. Thanks to :user:`RollingStar`. :bug:`3242` - Fix a bug that caused a crash when tagging items with the beatport plugin. :bug:`3374` - ``beet import`` now logs which files are ignored when in debug mode. :bug:`3764` - :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode. Thanks to :user:`aereaux`. :bug:`3437` - :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names :bug:`3446` - :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed. :bug:`3492` - Added a warning when configuration files defined in the ``include`` directive of the configuration file fail to be imported. :bug:`3498` - Added normalization to integer values in the database, which should avoid problems where fields like ``bpm`` would sometimes store non-integer values. :bug:`762` :bug:`3507` :bug:`3508` - Fix a crash when querying for null values. :bug:`3516` :bug:`3517` - :doc:`/plugins/lyrics`: Tolerate a missing lyrics div in the Genius scraper. Thanks to :user:`thejli21`. :bug:`3535` :bug:`3554` - :doc:`/plugins/lyrics`: Use the artist sort name to search for lyrics, which can help find matches when the artist name has special characters. Thanks to :user:`hashhar`. :bug:`3340` :bug:`3558` - :doc:`/plugins/replaygain`: Trying to calculate volume gain for an album consisting of some formats using ``ReplayGain`` and some using ``R128`` will no longer crash; instead it is skipped and and a message is logged. The log message has also been rewritten for to improve clarity. Thanks to :user:`autrimpo`. :bug:`3533` - :doc:`/plugins/lyrics`: Adapt the Genius backend to changes in markup to reduce the scraping failure rate. :bug:`3535` :bug:`3594` - :doc:`/plugins/lyrics`: Fix a crash when writing ReST files for a query without results or fetched lyrics. :bug:`2805` - :doc:`/plugins/fetchart`: Attempt to fetch pre-resized thumbnails from Cover Art Archive if the ``maxwidth`` option matches one of the sizes supported by the Cover Art Archive API. Thanks to :user:`trolley`. :bug:`3637` - :doc:`/plugins/ipfs`: Fix Python 3 compatibility. Thanks to :user:`musoke`. :bug:`2554` - Fix a bug that caused metadata starting with something resembling a drive letter to be incorrectly split into an extra directory after the colon. :bug:`3685` - :doc:`/plugins/mpdstats`: Don't record a skip when stopping MPD, as MPD keeps the current track in the queue. Thanks to :user:`aereaux`. :bug:`3722` - String-typed fields are now normalized to string values, avoiding an occasional crash when using both the :doc:`/plugins/fetchart` and the :doc:`/plugins/discogs` together. :bug:`3773` :bug:`3774` - Fix a bug causing PIL to generate poor quality JPEGs when resizing artwork. :bug:`3743` - :doc:`plugins/keyfinder`: Catch output from ``keyfinder-cli`` that is missing key. :bug:`2242` - :doc:`plugins/replaygain`: Disable parallel analysis on import by default. :bug:`3819` - :doc:`/plugins/mpdstats`: Fix Python 2/3 compatibility :bug:`3798` - :doc:`/plugins/discogs`: Replace the deprecated official ``discogs-client`` library with the community supported python3-discogs-client_ library. :bug:`3608` - :doc:`/plugins/chroma`: Fixed submitting AcoustID information for tracks that already have a fingerprint. :bug:`3834` - Allow equals within the value part of the ``--set`` option to the ``beet import`` command. :bug:`2984` - Duplicates can now generate checksums. Thanks :user:`wisp3rwind` for the pointer to how to solve. Thanks to :user:`arogl`. :bug:`2873` - Templates that use ``%ifdef`` now produce the expected behavior when used in conjunction with non-string fields from the :doc:`/plugins/types`. :bug:`3852` - :doc:`/plugins/lyrics`: Fix crashes when a website could not be retrieved, affecting at least the Genius source. :bug:`3970` - :doc:`/plugins/duplicates`: Fix a crash when running the ``dup`` command with a query that returns no results. :bug:`3943` - :doc:`/plugins/beatport`: Fix the default assignment of the musical key. :bug:`3377` - :doc:`/plugins/lyrics`: Improved searching on the Genius backend when the artist contains special characters. :bug:`3634` - :doc:`/plugins/parentwork`: Also get the composition date of the parent work, instead of just the child work. Thanks to :user:`aereaux`. :bug:`3650` - :doc:`/plugins/lyrics`: Fix a bug in the heuristic for detecting valid lyrics in the Google source. :bug:`2969` - :doc:`/plugins/thumbnails`: Fix a crash due to an incorrect string type on Python 3. :bug:`3360` - :doc:`/plugins/fetchart`: The Cover Art Archive source now iterates over all front images instead of blindly selecting the first one. - :doc:`/plugins/lyrics`: Removed the LyricWiki source (the site shut down on 21/09/2020). - :doc:`/plugins/subsonicupdate`: The plugin is now functional again. A new ``auth`` configuration option is required in the configuration to specify the flavor of authentication to use. :bug:`4002` For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - MediaFile_ has been split into a standalone project. Where you used to do ``from beets import mediafile``, now just do ``import mediafile``. Beets re-exports MediaFile at the old location for backwards-compatibility, but a deprecation warning is raised if you do this since we might drop this wrapper in a future release. - Similarly, we've replaced beets' configuration library (previously called Confit) with a standalone version called Confuse_. Where you used to do ``from beets.util import confit``, now just do ``import confuse``. The code is almost identical apart from the name change. Again, we'll re-export at the old location (with a deprecation warning) for backwards compatibility, but we might stop doing this in a future release. - ``beets.util.command_output`` now returns a named tuple containing both the standard output and the standard error data instead of just stdout alone. Client code will need to access the ``stdout`` attribute on the return value. Thanks to :user:`zsinskri`. :bug:`3329` - There were sporadic failures in ``test.test_player``. Hopefully these are fixed. If they resurface, please reopen the relevant issue. :bug:`3309` :bug:`3330` - The ``beets.plugins.MetadataSourcePlugin`` base class has been added to simplify development of plugins which query album, track, and search APIs to provide metadata matches for the importer. Refer to the :doc:`/plugins/spotify` and the :doc:`/plugins/deezer` for examples of using this template class. :bug:`3355` - Accessing fields on an ``Item`` now falls back to the album's attributes. So, for example, ``item.foo`` will first look for a field ``foo`` on ``item`` and, if it doesn't exist, next tries looking for a field named ``foo`` on the album that contains ``item``. If you specifically want to access an item's attributes, use ``Item.get(key, with_album=False)``. :bug:`2988` - ``Item.keys`` also has a ``with_album`` argument now, defaulting to ``True``. - A ``revision`` attribute has been added to ``Database``. It is increased on every transaction that mutates it. :bug:`2988` - The classes ``AlbumInfo`` and ``TrackInfo`` now convey arbitrary attributes instead of a fixed, built-in set of field names (which was important to address :bug:`1547`). Thanks to :user:`dosoe`. - Two new events, ``mb_album_extract`` and ``mb_track_extract``, let plugins add new fields based on MusicBrainz data. Thanks to :user:`dosoe`. For packagers ~~~~~~~~~~~~~ - Beets' library for manipulating media file metadata has now been split to a standalone project called MediaFile_, released as :pypi:`mediafile`. Beets now depends on this new package. Beets now depends on Mutagen transitively through MediaFile rather than directly, except in the case of one of beets' plugins (in particular, the :doc:`/plugins/scrub`). - Beets' library for configuration has been split into a standalone project called Confuse_, released as :pypi:`confuse`. Beets now depends on this package. Confuse has existed separately for some time and is used by unrelated projects, but until now we've been bundling a copy within beets. - We attempted to fix an unreliable test, so a patch to skip-broken-test_ or repairing_ may no longer be necessary. - This version drops support for Python 3.4. - We have removed an optional dependency on bs1770gain. .. _confuse: https://github.com/beetbox/confuse .. _deezer: https://www.deezer.com/en/ .. _fish shell: https://fishshell.com/ .. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli .. _last.fm: https://www.last.fm/ .. _mediafile: https://github.com/beetbox/mediafile .. _py7zr: https://pypi.org/project/py7zr/ .. _python3-discogs-client: https://github.com/joalla/discogs_client .. _repairing: https://build.opensuse.org/package/view_file/openSUSE:Factory/beets/fix_test_command_line_option_relative_to_working_dir.diff?expand=1 .. _skip-broken-test: https://sources.debian.org/src/beets/1.4.7-2/debian/patches/skip-broken-test/ .. _works: https://musicbrainz.org/doc/Work 1.4.9 (May 30, 2019) -------------------- This small update is part of our attempt to release new versions more often! There are a few important fixes, and we're clearing the deck for a change to beets' dependencies in the next version. The new feature is ~~~~~~~~~~~~~~~~~~ - You can use the NO_COLOR_ environment variable to disable terminal colors. :bug:`3273` There are some fixes in this release ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix a regression in the last release that made the image resizer fail to detect older versions of ImageMagick. :bug:`3269` - ``/plugins/gmusic``: The ``oauth_file`` config option now supports more flexible path values, including ``~`` for the home directory. :bug:`3270` - ``/plugins/gmusic``: Fix a crash when using version 12.0.0 or later of the ``gmusicapi`` module. :bug:`3270` - Fix an incompatibility with Python 3.8's AST changes. :bug:`3278` Here's a note for packagers ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ``pathlib`` is now an optional test dependency on Python 3.4+, removing the need for `Debian pathlib patch`_ :bug:`3275` .. _debian pathlib patch: https://sources.debian.org/src/beets/1.4.7-2/debian/patches/pathlib-is-stdlib/ .. _no_color: https://no-color.org 1.4.8 (May 16, 2019) -------------------- This release is far too long in coming, but it's a good one. There is the usual torrent of new features and a ridiculously long line of fixes, but there are also some crucial maintenance changes. We officially support Python 3.7 and 3.8, and some performance optimizations can (anecdotally) make listing your library more than three times faster than in the previous version. The new core features are ~~~~~~~~~~~~~~~~~~~~~~~~~ - A new :ref:`config-aunique` configuration option allows setting default options for the :ref:`aunique` template function. - The ``albumdisambig`` field no longer includes the MusicBrainz release group disambiguation comment. A new ``releasegroupdisambig`` field has been added. :bug:`3024` - The :ref:`modify-cmd` command now allows resetting fixed attributes. For example, ``beet modify -a artist:beatles artpath!`` resets ``artpath`` attribute from matching albums back to the default value. :bug:`2497` - A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks contained in data files. :bug:`3021` There are some new plugins ~~~~~~~~~~~~~~~~~~~~~~~~~~ - The :doc:`/plugins/playlist` can query the beets library using M3U playlists. Thanks to :user:`Holzhaus` and :user:`Xenopathic`. :bug:`123` :bug:`3145` - The :doc:`/plugins/loadext` allows loading of SQLite extensions, primarily for use with the ICU SQLite extension for internationalization. :bug:`3160` :bug:`3226` - The :doc:`/plugins/subsonicupdate` can automatically update your Subsonic library. Thanks to :user:`maffo999`. :bug:`3001` And many improvements to existing plugins ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/lastgenre`: Added option ``-A`` to match individual tracks and singletons. :bug:`3220` :bug:`3219` - :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` - ``/plugins/gmusic``: - Add a new option to automatically upload to Google Play Music library on track import. Thanks to :user:`shuaiscott`. - Add new options for Google Play Music authentication. Thanks to :user:`thetarkus`. :bug:`3002` - :doc:`/plugins/replaygain`: ``albumpeak`` on large collections is calculated as the average, not the maximum. :bug:`3008` :bug:`3009` - :doc:`/plugins/chroma`: - Now optionally has a bias toward looking up more relevant releases according to the :ref:`preferred` configuration options. Thanks to :user:`archer4499`. :bug:`3017` - Fingerprint values are now properly stored as strings, which prevents strange repeated output when running ``beet write``. Thanks to :user:`Holzhaus`. :bug:`3097` :bug:`2942` - :doc:`/plugins/convert`: The plugin now has an ``id3v23`` option that allows you to override the global ``id3v23`` option. Thanks to :user:`Holzhaus`. :bug:`3104` - :doc:`/plugins/spotify`: - The plugin now uses OAuth for authentication to the Spotify API. Thanks to :user:`rhlahuja`. :bug:`2694` :bug:`3123` - The plugin now works as an import metadata provider: you can match tracks and albums using the Spotify database. Thanks to :user:`rhlahuja`. :bug:`3123` - :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which passes that flag to ipfs. Thanks to :user:`wildthyme`. - :doc:`/plugins/discogs`: The plugin now has rate limiting for the Discogs API. :bug:`3081` - :doc:`/plugins/mpdstats`, :doc:`/plugins/mpdupdate`: These plugins now use the ``MPD_PORT`` environment variable if no port is specified in the configuration file. :bug:`3223` - :doc:`/plugins/bpd`: - MPD protocol commands ``consume`` and ``single`` are now supported along with updated semantics for ``repeat`` and ``previous`` and new fields for ``status``. The bpd server now understands and ignores some additional commands. :bug:`3200` :bug:`800` - MPD protocol command ``idle`` is now supported, allowing the MPD version to be bumped to 0.14. :bug:`3205` :bug:`800` - MPD protocol command ``decoders`` is now supported. :bug:`3222` - The plugin now uses the main beets logging system. The special-purpose ``--debug`` flag has been removed. Thanks to :user:`arcresu`. :bug:`3196` - :doc:`/plugins/mbsync`: The plugin no longer queries MusicBrainz when either the ``mb_albumid`` or ``mb_trackid`` field is invalid. See also the discussion on `Google Groups`_ Thanks to :user:`arogl`. - :doc:`/plugins/export`: The plugin now also exports ``path`` field if the user explicitly specifies it with ``-i`` parameter. This only works when exporting library fields. :bug:`3084` - :doc:`/plugins/acousticbrainz`: The plugin now declares types for all its fields, which enables easier querying and avoids a problem where very small numbers would be stored as strings. Thanks to :user:`rain0r`. :bug:`2790` :bug:`3238` .. _google groups: https://groups.google.com/forum/#!searchin/beets-users/mbsync|sort:date/beets-users/iwCF6bNdh9A/i1xl4Gx8BQAJ Some improvements have been focused on improving beets' performance ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Querying the library is now faster: - We only convert fields that need to be displayed. Thanks to :user:`pprkut`. :bug:`3089` - We now compile templates once and reuse them instead of recompiling them to print out each matching object. Thanks to :user:`SimonPersson`. :bug:`3258` - Querying the library for items is now faster, for all queries that do not need to access album level properties. This was implemented by lazily fetching the album only when needed. Thanks to :user:`SimonPersson`. :bug:`3260` - :doc:`/plugins/absubmit`, :doc:`/plugins/badfiles`: Analysis now works in parallel (on Python 3 only). Thanks to :user:`bemeurer`. :bug:`2442` :bug:`3003` - :doc:`/plugins/mpdstats`: Use the ``currentsong`` MPD command instead of ``playlist`` to get the current song, improving performance when the playlist is long. Thanks to :user:`ray66`. :bug:`3207` :bug:`2752` Several improvements are related to usability ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The disambiguation string for identifying albums in the importer now shows the catalog number. Thanks to :user:`8h2a`. :bug:`2951` - Added whitespace padding to missing tracks dialog to improve readability. Thanks to :user:`jams2`. :bug:`2962` - The :ref:`move-cmd` command now lists the number of items already in-place. Thanks to :user:`RollingStar`. :bug:`3117` - Modify selection can now be applied early without selecting every item. :bug:`3083` - Beets now emits more useful messages during startup if SQLite returns an error. The SQLite error message is now attached to the beets message. :bug:`3005` - Fixed a confusing typo when the :doc:`/plugins/convert` plugin copies the art covers. :bug:`3063` Many fixes have been focused on issues where beets would previously crash ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Avoid a crash when archive extraction fails during import. :bug:`3041` - Missing album art file during an update no longer causes a fatal exception (instead, an error is logged and the missing file path is removed from the library). :bug:`3030` - When updating the database, beets no longer tries to move album art twice. :bug:`3189` - Fix an unhandled exception when pruning empty directories. :bug:`1996` :bug:`3209` - :doc:`/plugins/fetchart`: Added network connection error handling to backends so that beets won't crash if a request fails. Thanks to :user:`Holzhaus`. :bug:`1579` - :doc:`/plugins/badfiles`: Avoid a crash when the underlying tool emits undecodable output. :bug:`3165` - :doc:`/plugins/beatport`: Avoid a crash when the server produces an error. :bug:`3184` - :doc:`/plugins/bpd`: Fix crashes in the bpd server during exception handling. :bug:`3200` - :doc:`/plugins/bpd`: Fix a crash triggered when certain clients tried to list the albums belonging to a particular artist. :bug:`3007` :bug:`3215` - :doc:`/plugins/replaygain`: Avoid a crash when the ``bs1770gain`` tool emits malformed XML. :bug:`2983` :bug:`3247` There are many fixes related to compatibility with our dependencies including addressing changes interfaces: - On Python 2, pin the :pypi:`jellyfish` requirement to version 0.6.0 for compatibility. - Fix compatibility with Python 3.7 and its change to a name in the :stdlib:`re` module. :bug:`2978` - Fix several uses of deprecated standard-library features on Python 3.7. Thanks to :user:`arcresu`. :bug:`3197` - Fix compatibility with pre-release versions of Python 3.8. :bug:`3201` :bug:`3202` - :doc:`/plugins/web`: Fix an error when using more recent versions of Flask with CORS enabled. Thanks to :user:`rveachkc`. :bug:`2979`: :bug:`2980` - Avoid some deprecation warnings with certain versions of the MusicBrainz library. Thanks to :user:`zhelezov`. :bug:`2826` :bug:`3092` - Restore iTunes Store album art source, and remove the dependency on :pypi:`python-itunes`, which had gone unmaintained and was not Python-3-compatible. Thanks to :user:`ocelma` for creating :pypi:`python-itunes` in the first place. Thanks to :user:`nathdwek`. :bug:`2371` :bug:`2551` :bug:`2718` - :doc:`/plugins/lastgenre`, :doc:`/plugins/edit`: Avoid a deprecation warnings from the :pypi:`PyYAML` library by switching to the safe loader. Thanks to :user:`translit` and :user:`sbraz`. :bug:`3192` :bug:`3225` - Fix a problem when resizing images with :pypi:`PIL`/:pypi:`pillow` on Python 3. Thanks to :user:`architek`. :bug:`2504` :bug:`3029` And there are many other fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - R128 normalization tags are now properly deleted from files when the values are missing. Thanks to :user:`autrimpo`. :bug:`2757` - Display the artist credit when matching albums if the :ref:`artist_credit` configuration option is set. :bug:`2953` - With the :ref:`from_scratch` configuration option set, only writable fields are cleared. Beets now no longer ignores the format your music is saved in. :bug:`2972` - The ``%aunique`` template function now works correctly with the ``-f/--format`` option. :bug:`3043` - Fixed the ordering of items when manually selecting changes while updating tags Thanks to :user:`TaizoSimpson`. :bug:`3501` - The ``%title`` template function now works correctly with apostrophes. Thanks to :user:`GuilhermeHideki`. :bug:`3033` - :doc:`/plugins/lastgenre`: It's now possible to set the ``prefer_specific`` option without also setting ``canonical``. :bug:`2973` - :doc:`/plugins/fetchart`: The plugin now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` - :doc:`/plugins/hook`: Fix byte string interpolation in hook commands. :bug:`2967` :bug:`3167` - :doc:`/plugins/the`: Log a message when something has changed, not when it hasn't. Thanks to :user:`arcresu`. :bug:`3195` - :doc:`/plugins/lastgenre`: The ``force`` config option now actually works. :bug:`2704` :bug:`3054` - Resizing image files with ImageMagick now avoids problems on systems where there is a ``convert`` command that is *not* ImageMagick's by using the ``magick`` executable when it is available. Thanks to :user:`ababyduck`. :bug:`2093` :bug:`3236` There is one new thing for plugin developers to know about ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - In addition to prefix-based field queries, plugins can now define *named queries* that are not associated with any specific field. For example, the new :doc:`/plugins/playlist` supports queries like ``playlist:name`` although there is no field named ``playlist``. See :ref:`extend-query` for details. And some messages for packagers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Note the changes to the dependencies on :pypi:`jellyfish` and :pypi:`munkres`. - The optional :pypi:`python-itunes` dependency has been removed. - Python versions 3.7 and 3.8 are now supported. 1.4.7 (May 29, 2018) -------------------- This new release includes lots of new features in the importer and the metadata source backends that it uses. We've changed how the beets importer handles non-audio tracks listed in metadata sources like MusicBrainz: - The importer now ignores non-audio tracks (namely, data and video tracks) listed in MusicBrainz. Also, a new option, :ref:`ignore_video_tracks`, lets you return to the old behavior and include these video tracks. :bug:`1210` - A new importer option, :ref:`ignored_media`, can let you skip certain media formats. :bug:`2688` There are other subtle improvements to metadata handling in the importer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - In the MusicBrainz backend, beets now imports the ``musicbrainz_releasetrackid`` field. This is a first step toward :bug:`406`. Thanks to :user:`Rawrmonkeys`. - A new importer configuration option, :ref:`artist_credit`, will tell beets to prefer the artist credit over the artist when autotagging. :bug:`1249` And there are even more new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/replaygain`: The ``beet replaygain`` command now has ``--force``, ``--write`` and ``--nowrite`` options. :bug:`2778` - A new importer configuration option, :ref:`incremental_skip_later`, lets you avoid recording skipped directories to the list of "processed" directories in :ref:`incremental` mode. This way, you can revisit them later with another import. Thanks to :user:`sekjun9878`. :bug:`2773` - :doc:`/plugins/fetchart`: The configuration options now support finer-grained control via the ``sources`` option. You can now specify the search order for different *matching strategies* within different backends. - :doc:`/plugins/web`: A new ``cors_supports_credentials`` configuration option lets in-browser clients communicate with the server even when it is protected by an authorization mechanism (a proxy with HTTP authentication enabled, for example). - A new :doc:`/plugins/sonosupdate` plugin automatically notifies Sonos controllers to update the music library when the beets library changes. Thanks to :user:`cgtobi`. - :doc:`/plugins/discogs`: The plugin now stores master release IDs into ``mb_releasegroupid``. It also "simulates" track IDs using the release ID and the track list position. Thanks to :user:`dbogdanov`. :bug:`2336` - :doc:`/plugins/discogs`: Fetch the original year from master releases. :bug:`1122` There are lots and lots of fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/replaygain`: Fix a corner-case with the ``bs1770gain`` backend where ReplayGain values were assigned to the wrong files. The plugin now requires version 0.4.6 or later of the ``bs1770gain`` tool. :bug:`2777` - :doc:`/plugins/lyrics`: The plugin no longer crashes in the Genius source when BeautifulSoup is not found. Instead, it just logs a message and disables the source. :bug:`2911` - :doc:`/plugins/lyrics`: Handle network and API errors when communicating with Genius. :bug:`2771` - :doc:`/plugins/lyrics`: The ``lyrics`` command previously wrote ReST files by default, even when you didn't ask for them. This default has been fixed. - :doc:`/plugins/lyrics`: When writing ReST files, the ``lyrics`` command now groups lyrics by the ``albumartist`` field, rather than ``artist``. :bug:`2924` - Plugins can now see updated import task state, such as when rejecting the initial candidates and finding new ones via a manual search. Notably, this means that the importer prompt options that the :doc:`/plugins/edit` provides show up more reliably after doing a secondary import search. :bug:`2441` :bug:`2731` - :doc:`/plugins/importadded`: Fix a crash on non-autotagged imports. Thanks to :user:`m42i`. :bug:`2601` :bug:`1918` - :doc:`/plugins/plexupdate`: The Plex token is now redacted in configuration output. Thanks to :user:`Kovrinic`. :bug:`2804` - Avoid a crash when importing a non-ASCII filename when using an ASCII locale on Unix under Python 3. :bug:`2793` :bug:`2803` - Fix a problem caused by time zone misalignment that could make date queries fail to match certain dates that are near the edges of a range. For example, querying for dates within a certain month would fail to match dates within hours of the end of that month. :bug:`2652` - :doc:`/plugins/convert`: The plugin now runs before other plugin-provided import stages, which addresses an issue with generating ReplayGain data incompatible between the source and target file formats. Thanks to :user:`autrimpo`. :bug:`2814` - :doc:`/plugins/ftintitle`: The ``drop`` config option had no effect; it now does what it says it should do. :bug:`2817` - Importing a release with multiple release events now selects the event based on the order of your :ref:`preferred` countries rather than the order of release events in MusicBrainz. :bug:`2816` - :doc:`/plugins/web`: The time display in the web interface would incorrectly jump at the 30-second mark of every minute. Now, it correctly changes over at zero seconds. :bug:`2822` - :doc:`/plugins/web`: Fetching album art now works (instead of throwing an exception) under Python 3. Additionally, the server will now return a 404 response when the album ID is unknown (instead of throwing an exception and producing a 500 response). :bug:`2823` - :doc:`/plugins/web`: Fix an exception on Python 3 for filenames with non-Latin1 characters. (These characters are now converted to their ASCII equivalents.) :bug:`2815` - Partially fix bash completion for subcommand names that contain hyphens. Thanks to :user:`jhermann`. :bug:`2836` :bug:`2837` - :doc:`/plugins/replaygain`: Really fix album gain calculation using the GStreamer backend. :bug:`2846` - Avoid an error when doing a "no-op" move on non-existent files (i.e., moving a file onto itself). :bug:`2863` - :doc:`/plugins/discogs`: Fix the ``medium`` and ``medium_index`` values, which were occasionally incorrect for releases with two-sided mediums such as vinyl. Also fix the ``medium_total`` value, which now contains total number of tracks on the medium to which a track belongs, not the total number of different mediums present on the release. Thanks to :user:`dbogdanov`. :bug:`2887` - The importer now supports audio files contained in data tracks when they are listed in MusicBrainz: the corresponding audio tracks are now merged into the main track list. Thanks to :user:`jdetrey`. :bug:`1638` - :doc:`/plugins/keyfinder`: Avoid a crash when trying to process unmatched tracks. :bug:`2537` - :doc:`/plugins/mbsync`: Support MusicBrainz recording ID changes, relying on release track IDs instead. Thanks to :user:`jdetrey`. :bug:`1234` - :doc:`/plugins/mbsync`: We can now successfully update albums even when the first track has a missing MusicBrainz recording ID. :bug:`2920` There are a couple of changes for developers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Plugins can now run their import stages *early*, before other plugins. Use the ``early_import_stages`` list instead of plain ``import_stages`` to request this behavior. :bug:`2814` - We again properly send ``albuminfo_received`` and ``trackinfo_received`` in all cases, most notably when using the ``mbsync`` plugin. This was a regression since version 1.4.1. :bug:`2921` 1.4.6 (December 21, 2017) ------------------------- The highlight of this release is "album merging," an oft-requested option in the importer to add new tracks to an existing album you already have in your library. This way, you no longer need to resort to removing the partial album from your library, combining the files manually, and importing again. Here are the larger new features in this release ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - When the importer finds duplicate albums, you can now merge all the tracks---old and new---together and try importing them as a single, combined album. Thanks to :user:`udiboy1209`. :bug:`112` :bug:`2725` - :doc:`/plugins/lyrics`: The plugin can now produce reStructuredText files for beautiful, readable books of lyrics. Thanks to :user:`anarcat`. :bug:`2628` - A new :ref:`from_scratch` configuration option makes the importer remove old metadata before applying new metadata. This new feature complements the :doc:`zero </plugins/zero>` and :doc:`scrub </plugins/scrub>` plugins but is slightly different: beets clears out all the old tags it knows about and only keeps the new data it gets from the remote metadata source. Thanks to :user:`tummychow`. :bug:`934` :bug:`2755` There are also somewhat littler, but still great, new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/convert`: A new ``no_convert`` option lets you skip transcoding items matching a query. Instead, the files are just copied as-is. Thanks to :user:`Stunner`. :bug:`2732` :bug:`2751` - :doc:`/plugins/fetchart`: A new quiet switch that only prints out messages when album art is missing. Thanks to :user:`euri10`. :bug:`2683` - :doc:`/plugins/mbcollection`: You can configure a custom MusicBrainz collection via the new ``collection`` configuration option. :bug:`2685` - :doc:`/plugins/mbcollection`: The collection update command can now remove albums from collections that are longer in the beets library. - :doc:`/plugins/fetchart`: The ``clearart`` command now asks for confirmation before touching your files. Thanks to :user:`konman2`. :bug:`2708` :bug:`2427` - :doc:`/plugins/mpdstats`: The plugin now correctly updates song statistics when MPD switches from a song to a stream and when it plays the same song multiple times consecutively. :bug:`2707` - :doc:`/plugins/acousticbrainz`: The plugin can now be configured to write only a specific list of tags. Thanks to :user:`woparry`. There are lots and lots of bug fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/hook`: Fixed a problem where accessing non-string properties of ``item`` or ``album`` (e.g., ``item.track``) would cause a crash. Thanks to :user:`broddo`. :bug:`2740` - :doc:`/plugins/play`: When ``relative_to`` is set, the plugin correctly emits relative paths even when querying for albums rather than tracks. Thanks to :user:`j000`. :bug:`2702` - We suppress a spurious Python warning about a ``BrokenPipeError`` being ignored. This was an issue when using beets in simple shell scripts. Thanks to :user:`Azphreal`. :bug:`2622` :bug:`2631` - :doc:`/plugins/replaygain`: Fix a regression in the previous release related to the new R128 tags. :bug:`2615` :bug:`2623` - :doc:`/plugins/lyrics`: The MusixMatch backend now detects and warns when the server has blocked the client. Thanks to :user:`anarcat`. :bug:`2634` :bug:`2632` - :doc:`/plugins/importfeeds`: Fix an error on Python 3 in certain configurations. Thanks to :user:`djl`. :bug:`2467` :bug:`2658` - :doc:`/plugins/edit`: Fix a bug when editing items during a re-import with the ``-L`` flag. Previously, diffs against against unrelated items could be shown or beets could crash. :bug:`2659` - :doc:`/plugins/kodiupdate`: Fix the server URL and add better error reporting. :bug:`2662` - Fixed a problem where "no-op" modifications would reset files' mtimes, resulting in unnecessary writes. This most prominently affected the :doc:`/plugins/edit` when saving the text file without making changes to some music. :bug:`2667` - :doc:`/plugins/chroma`: Fix a crash when running the ``submit`` command on Python 3 on Windows with non-ASCII filenames. :bug:`2671` - :doc:`/plugins/absubmit`: Fix an occasional crash on Python 3 when the AB analysis tool produced non-ASCII metadata. :bug:`2673` - :doc:`/plugins/duplicates`: Use the default tiebreak for items or albums when the configuration only specifies a tiebreak for the other kind of entity. Thanks to :user:`cgevans`. :bug:`2758` - :doc:`/plugins/duplicates`: Fix the ``--key`` command line option, which was ignored. - :doc:`/plugins/replaygain`: Fix album ReplayGain calculation with the GStreamer backend. :bug:`2636` - :doc:`/plugins/scrub`: Handle errors when manipulating files using newer versions of Mutagen. :bug:`2716` - :doc:`/plugins/fetchart`: The plugin no longer gets skipped during import when the "Edit Candidates" option is used from the :doc:`/plugins/edit`. :bug:`2734` - Fix a crash when numeric metadata fields contain just a minus or plus sign with no following numbers. Thanks to :user:`eigengrau`. :bug:`2741` - :doc:`/plugins/fromfilename`: Recognize file names that contain *only* a track number, such as ``01.mp3``. Also, the plugin now allows underscores as a separator between fields. Thanks to :user:`Vrihub`. :bug:`2738` :bug:`2759` - Fixed an issue where images would be resized according to their longest edge, instead of their width, when using the ``maxwidth`` config option in the :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`. Thanks to :user:`sekjun9878`. :bug:`2729` There are some changes for developers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - "Fixed fields" in Album and Item objects are now more strict about translating missing values into type-specific null-like values. This should help in cases where a string field is unexpectedly ``None`` sometimes instead of just showing up as an empty string. :bug:`2605` - Refactored the move functions the ``beets.library`` module and the ``manipulate_files`` function in ``beets.importer`` to use a single parameter describing the file operation instead of multiple Boolean flags. There is a new numerated type describing how to move, copy, or link files. :bug:`2682` 1.4.5 (June 20, 2017) --------------------- Version 1.4.5 adds some oft-requested features. When you're importing files, you can now manually set fields on the new music. Date queries have gotten much more powerful: you can write precise queries down to the second, and we now have *relative* queries like ``-1w``, which means *one week ago*. Here are the new features ~~~~~~~~~~~~~~~~~~~~~~~~~ - You can now set fields to certain values during :ref:`import-cmd`, using either a ``--set field=value`` command-line flag or a new :ref:`set_fields` configuration option under the ``importer`` section. Thanks to :user:`bartkl`. :bug:`1881` :bug:`2581` - :ref:`Date queries <datequery>` can now include times, so you can filter your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506` :bug:`2528` - :ref:`Date queries <datequery>` can also be *relative*. You can say ``added:-1w..`` to match music added in the last week, for example. Thanks to :user:`euri10`. :bug:`2598` - A new ``/plugins/gmusic`` lets you interact with your Google Play Music library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` - :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from classic ReplayGain data for formats that need it (namely, Ogg Opus). A new ``r128`` configuration option enables this behavior for specific formats. Thanks to :user:`autrimpo`. :bug:`2557` :bug:`2560` - The :ref:`move-cmd` command gained a new ``--export`` flag, which copies files to an external location without changing their paths in the library database. Thanks to :user:`SpirosChadoulos`. :bug:`435` :bug:`2510` There are also some bug fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/lastgenre`: Fix a crash when using the ``prefer_specific`` and ``canonical`` options together. Thanks to :user:`yacoob`. :bug:`2459` :bug:`2583` - :doc:`/plugins/web`: Fix a crash on Windows under Python 2 when serving non-ASCII filenames. Thanks to :user:`robot3498712`. :bug:`2592` :bug:`2593` - :doc:`/plugins/metasync`: Fix a crash in the Amarok backend when filenames contain quotes. Thanks to :user:`aranc23`. :bug:`2595` :bug:`2596` - More informative error messages are displayed when the file format is not recognized. :bug:`2599` 1.4.4 (June 10, 2017) --------------------- This release built up a longer-than-normal list of nifty new features. We now support DSF audio files and the importer can hard-link your files, for example. Here's a full list of new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Added support for DSF files, once a future version of Mutagen is released that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379` - A new :ref:`hardlink` config option instructs the importer to create hard links on filesystems that support them. Thanks to :user:`jacobwgillespie`. :bug:`2445` - A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync with beets. Thanks to :user:`Pauligrinder`. :bug:`2411` - A new :ref:`bell` configuration option under the ``import`` section enables a terminal bell when input is required. Thanks to :user:`SpirosChadoulos`. :bug:`2366` :bug:`2495` - A new field, ``composer_sort``, is now supported and fetched from MusicBrainz. Thanks to :user:`dosoe`. :bug:`2519` :bug:`2529` - The MusicBrainz backend and :doc:`/plugins/discogs` now both provide a new attribute called ``track_alt`` that stores more nuanced, possibly non-numeric track index data. For example, some vinyl or tape media will report the side of the record using a letter instead of a number in that field. :bug:`1831` :bug:`2363` - :doc:`/plugins/web`: Added a new endpoint, ``/item/path/foo``, which will return the item info for the file at the given path, or 404. - :doc:`/plugins/web`: Added a new config option, ``include_paths``, which will cause paths to be included in item API responses if set to true. - The ``%aunique`` template function for :ref:`aunique` now takes a third argument that specifies which brackets to use around the disambiguator value. The argument can be any two characters that represent the left and right brackets. It defaults to ``[]`` and can also be blank to turn off bracketing. :bug:`2397` :bug:`2399` - Added a ``--move`` or ``-m`` option to the importer so that the files can be moved to the library instead of being copied or added "in place." :bug:`2252` :bug:`2429` - :doc:`/plugins/badfiles`: Added a ``--verbose`` or ``-v`` option. Results are now displayed only for corrupted files by default and for all the files when the verbose option is set. :bug:`1654` :bug:`2434` - :doc:`/plugins/embedart`: The explicit ``embedart`` command now asks for confirmation before embedding art into music files. Thanks to :user:`Stunner`. :bug:`1999` - You can now run beets by typing ``python -m beets``. :bug:`2453` - :doc:`/plugins/smartplaylist`: Different playlist specifications that generate identically-named playlist files no longer conflict; instead, the resulting lists of tracks are concatenated. :bug:`2468` - :doc:`/plugins/missing`: A new mode lets you see missing albums from artists you have in your library. Thanks to :user:`qlyoung`. :bug:`2481` - :doc:`/plugins/web` : Add new ``reverse_proxy`` config option to allow serving the web plugins under a reverse proxy. - Importing a release with multiple release events now selects the event based on your :ref:`preferred` countries. :bug:`2501` - :doc:`/plugins/play`: A new ``-y`` or ``--yes`` parameter lets you skip the warning message if you enqueue more items than the warning threshold usually allows. - Fix a bug where commands which forked subprocesses would sometimes prevent further inputs. This bug mainly affected :doc:`/plugins/convert`. Thanks to :user:`jansol`. :bug:`2488` :bug:`2524` There are also quite a few fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - In the :ref:`replace` configuration option, we now replace a leading hyphen (-) with an underscore. :bug:`549` :bug:`2509` - :doc:`/plugins/absubmit`: We no longer filter audio files for specific formats---we will attempt the submission process for all formats. :bug:`2471` - :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381` - :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain`` backend. :bug:`2382` - :doc:`/plugins/bpd`: Report playback times as integers. :bug:`2394` - :doc:`/plugins/mpdstats`: Fix Python 3 compatibility. The plugin also now requires version 0.4.2 or later of the ``python-mpd2`` library. :bug:`2405` - :doc:`/plugins/mpdstats`: Improve handling of MPD status queries. - :doc:`/plugins/badfiles`: Fix Python 3 compatibility. - Fix some cases where album-level ReplayGain/SoundCheck metadata would be written to files incorrectly. :bug:`2426` - :doc:`/plugins/badfiles`: The command no longer bails out if the validator command is not found or exits with an error. :bug:`2430` :bug:`2433` - :doc:`/plugins/lyrics`: The Google search backend no longer crashes when the server responds with an error. :bug:`2437` - :doc:`/plugins/discogs`: You can now authenticate with Discogs using a personal access token. :bug:`2447` - Fix Python 3 compatibility when extracting rar archives in the importer. Thanks to :user:`Lompik`. :bug:`2443` :bug:`2448` - :doc:`/plugins/duplicates`: Fix Python 3 compatibility when using the ``copy`` and ``move`` options. :bug:`2444` - :doc:`/plugins/mbsubmit`: The tracks are now sorted properly. Thanks to :user:`awesomer`. :bug:`2457` - :doc:`/plugins/thumbnails`: Fix a string-related crash on Python 3. :bug:`2466` - :doc:`/plugins/beatport`: More than just 10 songs are now fetched per album. :bug:`2469` - On Python 3, the :ref:`terminal_encoding` setting is respected again for output and printing will no longer crash on systems configured with a limited encoding. - :doc:`/plugins/convert`: The default configuration uses FFmpeg's built-in AAC codec instead of faac. Thanks to :user:`jansol`. :bug:`2484` - Fix the importer's detection of multi-disc albums when other subdirectories are present. :bug:`2493` - Invalid date queries now print an error message instead of being silently ignored. Thanks to :user:`discopatrick`. :bug:`2513` :bug:`2517` - When the SQLite database stops being accessible, we now print a friendly error message. Thanks to :user:`Mary011196`. :bug:`1676` :bug:`2508` - :doc:`/plugins/web`: Avoid a crash when sending binary data, such as Chromaprint fingerprints, in music attributes. :bug:`2542` :bug:`2532` - Fix a hang when parsing templates that end in newlines. :bug:`2562` - Fix a crash when reading non-ASCII characters in configuration files on Windows under Python 3. :bug:`2456` :bug:`2565` :bug:`2566` We removed backends from two metadata plugins because of bitrot ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/lyrics`: The Lyrics.com backend has been removed. (It stopped working because of changes to the site's URL structure.) :bug:`2548` :bug:`2549` - :doc:`/plugins/fetchart`: The documentation no longer recommends iTunes Store artwork lookup because the unmaintained python-itunes_ is broken. Want to adopt it? :bug:`2371` :bug:`1610` .. _python-itunes: https://github.com/ocelma/python-itunes 1.4.3 (January 9, 2017) ----------------------- Happy new year! This new version includes a cornucopia of new features from contributors, including new tags related to classical music and a new :doc:`/plugins/absubmit` for performing acoustic analysis on your music. The :doc:`/plugins/random` has a new mode that lets you generate time-limited music---for example, you might generate a random playlist that lasts the perfect length for your walk to work. We also access as many Web services as possible over secure connections now---HTTPS everywhere! The most visible new features are ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - We now support the composer, lyricist, and arranger tags. The MusicBrainz data source will fetch data for these fields when the next version of python-musicbrainzngs_ is released. Thanks to :user:`ibmibmibm`. :bug:`506` :bug:`507` :bug:`1547` :bug:`2333` - A new :doc:`/plugins/absubmit` lets you run acoustic analysis software and upload the results for others to use. Thanks to :user:`inytar`. :bug:`2253` :bug:`2342` - :doc:`/plugins/play`: The plugin now provides an importer prompt choice to play the music you're about to import. Thanks to :user:`diomekes`. :bug:`2008` :bug:`2360` - We now use SSL to access Web services whenever possible. That includes MusicBrainz itself, several album art sources, some lyrics sources, and other servers. Thanks to :user:`tigranl`. :bug:`2307` - :doc:`/plugins/random`: A new ``--time`` option lets you generate a random playlist that takes a given amount of time. Thanks to :user:`diomekes`. :bug:`2305` :bug:`2322` Some smaller new features ~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/zero`: A new ``zero`` command manually triggers the zero plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329` - :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data for files that already have it by default. You can override this behavior using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347` :bug:`2349` - :doc:`/plugins/bpm`: The ``import.write`` configuration option now decides whether or not to write tracks after updating their BPM. :bug:`1992` And the fixes ~~~~~~~~~~~~~ - :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332` - :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written. :bug:`2351` - :doc:`/plugins/scrub`: The image type values on scrubbed files are preserved instead of being reset to "other." :bug:`2339` - :doc:`/plugins/web`: Fix a crash on Python 3 when serving files from the filesystem. :bug:`2353` - :doc:`/plugins/discogs`: Improve the handling of releases that contain subtracks. :bug:`2318` - :doc:`/plugins/discogs`: Fix a crash when a release does not contain format information, and increase robustness when other fields are missing. :bug:`2302` - :doc:`/plugins/lyrics`: The plugin now reports a beets-specific User-Agent header when requesting lyrics. :bug:`2357` - :doc:`/plugins/embyupdate`: The plugin now checks whether an API key or a password is provided in the configuration. - :doc:`/plugins/play`: The misspelled configuration option ``warning_treshold`` is no longer supported. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ - :ref:`append_prompt_choices`: When providing new importer prompt choices, you can now provide new candidates for the user to consider. For example, you might provide an alternative strategy for picking between the available alternatives or for looking up a release on MusicBrainz. 1.4.2 (December 16, 2016) ------------------------- This is just a little bug fix release. With 1.4.2, we're also confident enough to recommend that anyone who's interested give Python 3 a try: bugs may still lurk, but we've deemed things safe enough for broad adoption. If you can, please install beets with ``pip3`` instead of ``pip2`` this time and let us know how it goes! Here are the fixes ~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/badfiles`: Fix a crash on non-ASCII filenames. :bug:`2299` - The ``%asciify{}`` path formatting function and the :ref:`asciify-paths` setting properly substitute path separators generated by converting some Unicode characters, such as ½ and ¢, into ASCII. - :doc:`/plugins/convert`: Fix a logging-related crash when filenames contain curly braces. Thanks to :user:`kierdavis`. :bug:`2323` - We've rolled back some changes to the included zsh completion script that were causing problems for some users. :bug:`2266` Also, we've removed some special handling for logging in the :doc:`/plugins/discogs` that we believe was unnecessary. If spurious log messages appear in this version, please let us know by filing a bug. 1.4.1 (November 25, 2016) ------------------------- Version 1.4 has **alpha-level** Python 3 support. Thanks to the heroic efforts of :user:`jrobeson`, beets should run both under Python 2.7, as before, and now under Python 3.4 and above. The support is still new: it undoubtedly contains bugs, so it may replace all your music with Limp Bizkit---but if you're brave and you have backups, please try installing on Python 3. Let us know how it goes. If you package beets for distribution, here's what you'll want to know ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - This version of beets now depends on the six_ library. - We also bumped our minimum required version of Mutagen_ to 1.33 (from 1.27). - Please don't package beets as a Python 3 application *yet*, even though most things work under Python 3.4 and later. This version also makes a few changes to the command-line interface and configuration that you may need to know about: - :doc:`/plugins/duplicates`: The ``duplicates`` command no longer accepts multiple field arguments in the form ``-k title albumartist album``. Each argument must be prefixed with ``-k``, as in ``-k title -k albumartist -k album``. - The old top-level ``colors`` configuration option has been removed (the setting is now under ``ui``). - The deprecated ``list_format_album`` and ``list_format_item`` configuration options have been removed (see :ref:`format_album` and :ref:`format_item`). The are a few new features ~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/mpdupdate`, :doc:`/plugins/mpdstats`: When the ``host`` option is not set, these plugins will now look for the ``$MPD_HOST`` environment variable before falling back to ``localhost``. Thanks to :user:`tarruda`. :bug:`2175` - :doc:`/plugins/web`: Added an ``expand`` option to show the items of an album. :bug:`2050` - :doc:`/plugins/embyupdate`: The plugin can now use an API key instead of a password to authenticate with Emby. :bug:`2045` :bug:`2117` - :doc:`/plugins/acousticbrainz`: The plugin now adds a ``bpm`` field. - ``beet --version`` now includes the Python version used to run beets. - :doc:`/reference/pathformat` can now include unescaped commas (``,``) when they are not part of a function call. :bug:`2166` :bug:`2213` - The :ref:`update-cmd` command takes a new ``-F`` flag to specify the fields to update. Thanks to :user:`dangmai`. :bug:`2229` :bug:`2231` And there are a few bug fixes too ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/convert`: The plugin no longer asks for confirmation if the query did not return anything to convert. :bug:`2260` :bug:`2262` - :doc:`/plugins/embedart`: The plugin now uses ``jpg`` as an extension rather than ``jpeg``, to ensure consistency with the :doc:`plugins/fetchart`. Thanks to :user:`tweitzel`. :bug:`2254` :bug:`2255` - :doc:`/plugins/embedart`: The plugin now works for all jpeg files, including those that are only recognizable by their magic bytes. :bug:`1545` :bug:`2255` - :doc:`/plugins/web`: The JSON output is no longer pretty-printed (for a space savings). :bug:`2050` - :doc:`/plugins/permissions`: Fix a regression in the previous release where the plugin would always fail to set permissions (and log a warning). :bug:`2089` - :doc:`/plugins/beatport`: Use track numbers from Beatport (instead of determining them from the order of tracks) and set the ``medium_index`` value. - With :ref:`per_disc_numbering` enabled, some metadata sources (notably, the :doc:`/plugins/beatport`) would not set the track number at all. This is fixed. :bug:`2085` - :doc:`/plugins/play`: Fix ``$args`` getting passed verbatim to the play command if it was set in the configuration but ``-A`` or ``--args`` was omitted. - With :ref:`ignore_hidden` enabled, non-UTF-8 filenames would cause a crash. This is fixed. :bug:`2168` - :doc:`/plugins/embyupdate`: Fixes authentication header problem that caused a problem that it was not possible to get tokens from the Emby API. - :doc:`/plugins/lyrics`: Some titles use a colon to separate the main title from a subtitle. To find more matches, the plugin now also searches for lyrics using the part part preceding the colon character. :bug:`2206` - Fix a crash when a query uses a date field and some items are missing that field. :bug:`1938` - :doc:`/plugins/discogs`: Subtracks are now detected and combined into a single track, two-sided mediums are treated as single discs, and tracks have ``media``, ``medium_total`` and ``medium`` set correctly. :bug:`2222` :bug:`2228`. - :doc:`/plugins/missing`: ``missing`` is now treated as an integer, allowing the use of (for example) ranges in queries. - :doc:`/plugins/smartplaylist`: Playlist names will be sanitized to ensure valid filenames. :bug:`2258` - The ID3 APIC tag now uses the Latin-1 encoding when possible instead of a Unicode encoding. This should increase compatibility with other software, especially with iTunes and when using ID3v2.3. Thanks to :user:`lazka`. :bug:`899` :bug:`2264` :bug:`2270` The last release, 1.3.19, also erroneously reported its version as "1.3.18" when you typed ``beet version``. This has been corrected. .. _six: https://pypi.org/project/six/ 1.3.19 (June 25, 2016) ---------------------- This is primarily a bug fix release: it cleans up a couple of regressions that appeared in the last version. But it also features the triumphant return of the :doc:`/plugins/beatport` and a modernized :doc:`/plugins/bpd`. It's also the first version where beets passes all its tests on Windows! May this herald a new age of cross-platform reliability for beets. New features ~~~~~~~~~~~~ - :doc:`/plugins/beatport`: This metadata source plugin has arisen from the dead! It now works with Beatport's new OAuth-based API. Thanks to :user:`jbaiter`. :bug:`1989` :bug:`2067` - :doc:`/plugins/bpd`: The plugin now uses the modern GStreamer 1.0 instead of the old 0.10. Thanks to :user:`philippbeckmann`. :bug:`2057` :bug:`2062` - A new ``--force`` option for the :ref:`remove-cmd` command allows removal of items without prompting beforehand. :bug:`2042` - A new :ref:`duplicate_action` importer config option controls how duplicate albums or tracks treated in import task. :bug:`185` Some fixes for Windows ~~~~~~~~~~~~~~~~~~~~~~ - Queries are now detected as paths when they contain backslashes (in addition to forward slashes). This only applies on Windows. - :doc:`/plugins/embedart`: Image similarity comparison with ImageMagick should now work on Windows. - :doc:`/plugins/fetchart`: The plugin should work more reliably with non-ASCII paths. And other fixes ~~~~~~~~~~~~~~~ - :doc:`/plugins/replaygain`: The ``bs1770gain`` backend now correctly calculates sample peak instead of true peak. This comes with a major speed increase. :bug:`2031` - :doc:`/plugins/lyrics`: Avoid a crash and a spurious warning introduced in the last version about a Google API key, which appeared even when you hadn't enabled the Google lyrics source. - Fix a hard-coded path to ``bash-completion`` to work better with Homebrew installations. Thanks to :user:`bismark`. :bug:`2038` - Fix a crash introduced in the previous version when the standard input was connected to a Unix pipe. :bug:`2041` - Fix a crash when specifying non-ASCII format strings on the command line with the ``-f`` option for many commands. :bug:`2063` - :doc:`/plugins/fetchart`: Determine the file extension for downloaded images based on the image's magic bytes. The plugin prints a warning if result is not consistent with the server-supplied ``Content-Type`` header. In previous versions, the plugin would use a ``.jpg`` extension for all images. :bug:`2053` 1.3.18 (May 31, 2016) --------------------- This update adds a new :doc:`/plugins/hook` that lets you integrate beets with command-line tools and an :doc:`/plugins/export` that can dump data from the beets database as JSON. You can also automatically translate lyrics using a machine translation service. The ``echonest`` plugin has been removed in this version because the API it used is `shutting down`_. You might want to try the :doc:`/plugins/acousticbrainz` instead. .. _shutting down: https://web.archive.org/web/20260000000000*/developer.spotify.com/news-stories/2016/03/29/api-improvements-update Some of the larger new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The new :doc:`/plugins/hook` lets you execute commands in response to beets events. - The new :doc:`/plugins/export` can export data from beets' database as JSON. Thanks to :user:`GuilhermeHideki`. - :doc:`/plugins/lyrics`: The plugin can now translate the fetched lyrics to your native language using the Bing translation API. Thanks to :user:`Kraymer`. - :doc:`/plugins/fetchart`: Album art can now be fetched from fanart.tv_. Smaller new things ~~~~~~~~~~~~~~~~~~ - There are two new functions available in templates: ``%first`` and ``%ifdef``. See :ref:`template-functions`. - :doc:`/plugins/convert`: A new ``album_art_maxwidth`` setting lets you resize album art while copying it. - :doc:`/plugins/convert`: The ``extension`` setting is now optional for conversion formats. By default, the extension is the same as the name of the configured format. - :doc:`/plugins/importadded`: A new ``preserve_write_mtimes`` option lets you preserve mtime of files even when beets updates their metadata. - :doc:`/plugins/fetchart`: The ``enforce_ratio`` option now lets you tolerate images that are *almost* square but differ slightly from an exact 1:1 aspect ratio. - :doc:`/plugins/fetchart`: The plugin can now optionally save the artwork's source in an attribute in the database. - The :ref:`terminal_encoding` configuration option can now also override the *input* encoding. (Previously, it only affected the encoding of the standard *output* stream.) - A new :ref:`ignore_hidden` configuration option lets you ignore files that your OS marks as invisible. - :doc:`/plugins/web`: A new ``values`` endpoint lets you get the distinct values of a field. Thanks to :user:`sumpfralle`. :bug:`2010` .. _fanart.tv: https://fanart.tv/ Fixes ~~~~~ - Fix a problem with the :ref:`stats-cmd` command in exact mode when filenames on Windows use non-ASCII characters. :bug:`1891` - Fix a crash when iTunes Sound Check tags contained invalid data. :bug:`1895` - :doc:`/plugins/mbcollection`: The plugin now redacts your MusicBrainz password in the ``beet config`` output. :bug:`1907` - :doc:`/plugins/scrub`: Fix an occasional problem where scrubbing on import could undo the :ref:`id3v23` setting. :bug:`1903` - :doc:`/plugins/lyrics`: Add compatibility with some changes to the LyricsWiki page markup. :bug:`1912` :bug:`1909` - :doc:`/plugins/lyrics`: Fix retrieval from Musixmatch by improving the way we guess the URL for lyrics on that service. :bug:`1880` - :doc:`/plugins/edit`: Fail gracefully when the configured text editor command can't be invoked. :bug:`1927` - :doc:`/plugins/fetchart`: Fix a crash in the Wikipedia backend on non-ASCII artist and album names. :bug:`1960` - :doc:`/plugins/convert`: Change the default ``ogg`` encoding quality from 2 to 3 (to fit the default from the ``oggenc(1)`` manpage). :bug:`1982` - :doc:`/plugins/convert`: The ``never_convert_lossy_files`` option now considers AIFF a lossless format. :bug:`2005` - :doc:`/plugins/web`: A proper 404 error, instead of an internal exception, is returned when missing album art is requested. Thanks to :user:`sumpfralle`. :bug:`2011` - Tolerate more malformed floating-point numbers in metadata tags. :bug:`2014` - The :ref:`ignore` configuration option now includes the ``lost+found`` directory by default. - :doc:`/plugins/acousticbrainz`: AcousticBrainz lookups are now done over HTTPS. Thanks to :user:`Freso`. :bug:`2007` 1.3.17 (February 7, 2016) ------------------------- This release introduces one new plugin to fetch audio information from the AcousticBrainz_ project and another plugin to make it easier to submit your handcrafted metadata back to MusicBrainz. The importer also gained two oft-requested features: a way to skip the initial search process by specifying an ID ahead of time, and a way to *manually* provide metadata in the middle of the import process (via the :doc:`/plugins/edit`). Also, as of this release, the beets project has some new Internet homes! Our new domain name is beets.io_, and we have a shiny new GitHub organization: beetbox_. Here are the big new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - A new :doc:`/plugins/acousticbrainz` fetches acoustic-analysis information from the AcousticBrainz_ project. Thanks to :user:`opatel99`, and thanks to `Google Code-In`_! :bug:`1784` - A new :doc:`/plugins/mbsubmit` lets you print music's current metadata in a format that the MusicBrainz data parser can understand. You can trigger it during an interactive import session. :bug:`1779` - A new ``--search-id`` importer option lets you manually specify IDs (i.e., MBIDs or Discogs IDs) for imported music. Doing this skips the initial candidate search, which can be important for huge albums where this initial lookup is slow. Also, the ``enter Id`` prompt choice now accepts several IDs, separated by spaces. :bug:`1808` - :doc:`/plugins/edit`: You can now edit metadata *on the fly* during the import process. The plugin provides two new interactive options: one to edit *your music's* metadata, and one to edit the *matched metadata* retrieved from MusicBrainz (or another data source). This feature is still in its early stages, so please send feedback if you find anything missing. :bug:`1846` :bug:`396` There are even more new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/fetchart`: The Google Images backend has been restored. It now requires an API key from Google. Thanks to :user:`lcharlick`. :bug:`1778` - :doc:`/plugins/info`: A new option will print only fields' names and not their values. Thanks to :user:`GuilhermeHideki`. :bug:`1812` - The :ref:`fields-cmd` command now displays flexible attributes. Thanks to :user:`GuilhermeHideki`. :bug:`1818` - The :ref:`modify-cmd` command lets you interactively select which albums or items you want to change. :bug:`1843` - The :ref:`move-cmd` command gained a new ``--timid`` flag to print and confirm which files you want to move. :bug:`1843` - The :ref:`move-cmd` command no longer prints filenames for files that don't actually need to be moved. :bug:`1583` .. _acousticbrainz: https://acousticbrainz.org/ .. _google code-in: https://codein.withgoogle.com/archive/ Fixes ~~~~~ - :doc:`/plugins/play`: Fix a regression in the last version where there was no default command. :bug:`1793` - :doc:`/plugins/lastimport`: The plugin now works again after being broken by some unannounced changes to the Last.fm API. :bug:`1574` - :doc:`/plugins/play`: Fixed a typo in a configuration option. The option is now ``warning_threshold`` instead of ``warning_treshold``, but we kept the old name around for compatibility. Thanks to :user:`JesseWeinstein`. :bug:`1802` :bug:`1803` - :doc:`/plugins/edit`: Editing metadata now moves files, when appropriate (like the :ref:`modify-cmd` command). :bug:`1804` - The :ref:`stats-cmd` command no longer crashes when files are missing or inaccessible. :bug:`1806` - :doc:`/plugins/fetchart`: Possibly fix a Unicode-related crash when using some versions of pyOpenSSL. :bug:`1805` - :doc:`/plugins/replaygain`: Fix an intermittent crash with the GStreamer backend. :bug:`1855` - :doc:`/plugins/lastimport`: The plugin now works with the beets API key by default. You can still provide a different key the configuration. - :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools backend. :bug:`1873` .. _beetbox: https://github.com/beetbox .. _beets.io: https://beets.io/ 1.3.16 (December 28, 2015) -------------------------- The big news in this release is a new :doc:`interactive editor plugin </plugins/edit>`. It's really nifty: you can now change your music's metadata by making changes in a visual text editor, which can sometimes be far more efficient than the built-in :ref:`modify-cmd` command. No more carefully retyping the same artist name with slight capitalization changes. This version also adds an oft-requested "not" operator to beets' queries, so you can exclude music from any operation. It also brings friendlier formatting (and querying!) of song durations. The big new stuff ~~~~~~~~~~~~~~~~~ - A new :doc:`/plugins/edit` lets you manually edit your music's metadata using your favorite text editor. :bug:`164` :bug:`1706` - Queries can now use "not" logic. Type a ``^`` before part of a query to *exclude* matching music from the results. For example, ``beet list -a beatles ^album:1`` will find all your albums by the Beatles except for their singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` - A new :doc:`/plugins/embyupdate` can trigger a library refresh on an Emby_ server when your beets database changes. - Track length is now displayed as "M:SS" rather than a raw number of seconds. Queries on track length also accept this format: for example, ``beet list length:5:30..`` will find all your tracks that have a duration over 5 minutes and 30 seconds. You can turn off this new behavior using the ``format_raw_length`` configuration option. :bug:`1749` Smaller changes ~~~~~~~~~~~~~~~ - Three commands, ``modify``, ``update``, and ``mbsync``, would previously move files by default after changing their metadata. Now, these commands will only move files if you have the :ref:`config-import-copy` or :ref:`config-import-move` options enabled in your importer configuration. This way, if you configure the importer not to touch your filenames, other commands will respect that decision by default too. Each command also sprouted a ``--move`` command-line option to override this default (in addition to the ``--nomove`` flag they already had). :bug:`1697` - A new configuration option, ``va_name``, controls the album artist name for various-artists albums. The setting defaults to "Various Artists," the MusicBrainz standard. In order to match MusicBrainz, the :doc:`/plugins/discogs` also adopts the same setting. - :doc:`/plugins/info`: The ``info`` command now accepts a ``-f/--format`` option for customizing how items are displayed, just like the built-in ``list`` command. :bug:`1737` Some changes for developers ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Two new :ref:`plugin hooks <plugin_events>`, ``albuminfo_received`` and ``trackinfo_received``, let plugins intercept metadata as soon as it is received, before it is applied to music in the database. :bug:`872` - Plugins can now add options to the interactive importer prompts. See :ref:`append_prompt_choices`. :bug:`1758` Fixes ~~~~~ - :doc:`/plugins/plexupdate`: Fix a crash when Plex libraries use non-ASCII collection names. :bug:`1649` - :doc:`/plugins/discogs`: Maybe fix a crash when using some versions of the ``requests`` library. :bug:`1656` - Fix a race in the importer when importing two albums with the same artist and name in quick succession. The importer would fail to detect them as duplicates, claiming that there were "empty albums" in the database even when there were not. :bug:`1652` - :doc:`plugins/lastgenre`: Clean up the reggae-related genres somewhat. Thanks to :user:`Freso`. :bug:`1661` - The importer now correctly moves album art files when re-importing. :bug:`314` - :doc:`/plugins/fetchart`: In auto mode, the plugin now skips albums that already have art attached to them so as not to interfere with re-imports. :bug:`314` - :doc:`plugins/fetchart`: The plugin now only resizes album art if necessary, rather than always by default. :bug:`1264` - :doc:`plugins/fetchart`: Fix a bug where a database reference to a non-existent album art file would prevent the command from fetching new art. :bug:`1126` - :doc:`/plugins/thumbnails`: Fix a crash with Unicode paths. :bug:`1686` - :doc:`/plugins/embedart`: The ``remove_art_file`` option now works on import (as well as with the explicit command). :bug:`1662` :bug:`1675` - :doc:`/plugins/metasync`: Fix a crash when syncing with recent versions of iTunes. :bug:`1700` - :doc:`/plugins/duplicates`: Fix a crash when merging items. :bug:`1699` - :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and missing configuration. - Fix a crash with some files with unreadable iTunes SoundCheck metadata. :bug:`1666` - :doc:`/plugins/thumbnails`: Fix a nasty segmentation fault crash that arose with some library versions. :bug:`1433` - :doc:`/plugins/convert`: Fix a crash with Unicode paths in ``--pretend`` mode. :bug:`1735` - Fix a crash when sorting by nonexistent fields on queries. :bug:`1734` - Probably fix some mysterious errors when dealing with images using ImageMagick on Windows. :bug:`1721` - Fix a crash when writing some Unicode comment strings to MP3s that used older encodings. The encoding is now always updated to UTF-8. :bug:`879` - :doc:`/plugins/fetchart`: The Google Images backend has been removed. It used an API that has been shut down. :bug:`1760` - :doc:`/plugins/lyrics`: Fix a crash in the Google backend when searching for bands with regular-expression characters in their names, like Sunn O))). :bug:`1673` - :doc:`/plugins/scrub`: In ``auto`` mode, the plugin now *actually* only scrubs files on import, as the documentation always claimed it did---not every time files were written, as it previously did. :bug:`1657` - :doc:`/plugins/scrub`: Also in ``auto`` mode, album art is now correctly restored. :bug:`1657` - Possibly allow flexible attributes to be used with the ``%aunique`` template function. :bug:`1775` - :doc:`/plugins/lyrics`: The Genius backend is now more robust to communication errors. The backend has also been disabled by default, since the API it depends on is currently down. :bug:`1770` .. _emby: https://emby.media 1.3.15 (October 17, 2015) ------------------------- This release adds a new plugin for checking file quality and a new source for lyrics. The larger features are: - A new :doc:`/plugins/badfiles` helps you scan for corruption in your music collection. Thanks to :user:`fxthomas`. :bug:`1568` - :doc:`/plugins/lyrics`: You can now fetch lyrics from Genius.com. Thanks to :user:`sadatay`. :bug:`1626` :bug:`1639` - :doc:`/plugins/zero`: The plugin can now use a "whitelist" policy as an alternative to the (default) "blacklist" mode. Thanks to :user:`adkow`. :bug:`1621` :bug:`1641` And there are smaller new features too ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add new color aliases for standard terminal color names (e.g., cyan and magenta). Thanks to :user:`mathstuf`. :bug:`1548` - :doc:`/plugins/play`: A new ``--args`` option lets you specify options for the player command. :bug:`1532` - :doc:`/plugins/play`: A new ``raw`` configuration option lets the command work with players (such as VLC) that expect music filenames as arguments, rather than in a playlist. Thanks to :user:`nathdwek`. :bug:`1578` - :doc:`/plugins/play`: You can now configure the number of tracks that trigger a "lots of music" warning. :bug:`1577` - :doc:`/plugins/embedart`: A new ``remove_art_file`` option lets you clean up if you prefer *only* embedded album art. Thanks to :user:`jackwilsdon`. :bug:`1591` :bug:`733` - :doc:`/plugins/plexupdate`: A new ``library_name`` option allows you to select which Plex library to update. :bug:`1572` :bug:`1595` - A new ``include`` option lets you import external configuration files. This release has plenty of fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/lastgenre`: Fix a bug that prevented tag popularity from being considered. Thanks to :user:`svoos`. :bug:`1559` - Fixed a bug where plugins wouldn't be notified of the deletion of an item's art, for example with the ``clearart`` command from the :doc:`/plugins/embedart`. Thanks to :user:`nathdwek`. :bug:`1565` - :doc:`/plugins/fetchart`: The Google Images source is disabled by default (as it was before beets 1.3.9), as is the Wikipedia source (which was causing lots of unnecessary delays due to DBpedia downtime). To re-enable these sources, add ``wikipedia google`` to your ``sources`` configuration option. - The :ref:`list-cmd` command's help output now has a small query and format string example. Thanks to :user:`pkess`. :bug:`1582` - :doc:`/plugins/fetchart`: The plugin now fetches PNGs but not GIFs. (It still fetches JPEGs.) This avoids an error when trying to embed images, since not all formats support GIFs. :bug:`1588` - Date fields are now written in the correct order (year-month-day), which eliminates an intermittent bug where the latter two fields would not get written to files. Thanks to :user:`jdetrey`. :bug:`1303` :bug:`1589` - :doc:`/plugins/replaygain`: Avoid a crash when the PyAudioTools backend encounters an error. :bug:`1592` - The case sensitivity of path queries is more useful now: rather than just guessing based on the platform, we now check the case sensitivity of your filesystem. :bug:`1586` - Case-insensitive path queries might have returned nothing because of a wrong SQL query. - Fix a crash when a query contains a "+" or "-" alone in a component. :bug:`1605` - Fixed unit of file size to powers of two (MiB, GiB, etc.) instead of powers of ten (MB, GB, etc.). :bug:`1623` 1.3.14 (August 2, 2015) ----------------------- This is mainly a bugfix release, but we also have a nifty new plugin for ipfs_ and a bunch of new configuration options. The new features ~~~~~~~~~~~~~~~~ - A new :doc:`/plugins/ipfs` lets you share music via a new, global, decentralized filesystem. :bug:`1397` - :doc:`/plugins/duplicates`: You can now merge duplicate track metadata (when detecting duplicate items), or duplicate album tracks (when detecting duplicate albums). - :doc:`/plugins/duplicates`: Duplicate resolution now uses an ordering to prioritize duplicates. By default, it prefers music with more complete metadata, but you can configure it to use any list of attributes. - :doc:`/plugins/metasync`: Added a new backend to fetch metadata from iTunes. This plugin is still in an experimental phase. :bug:`1450` - The ``move`` command has a new ``--pretend`` option, making the command show how the items will be moved without actually changing anything. - The importer now supports matching of "pregap" or HTOA (hidden track-one audio) tracks when they are listed in MusicBrainz. (This feature depends on a new version of the python-musicbrainzngs_ library that is not yet released, but will start working when it is available.) Thanks to :user:`ruippeixotog`. :bug:`1104` :bug:`1493` - :doc:`/plugins/plexupdate`: A new ``token`` configuration option lets you specify a key for Plex Home setups. Thanks to :user:`edcarroll`. :bug:`1494` Fixes ~~~~~ - :doc:`/plugins/fetchart`: Complain when the ``enforce_ratio`` or ``min_width`` options are enabled but no local imaging backend is available to carry them out. :bug:`1460` - :doc:`/plugins/importfeeds`: Avoid generating incorrect m3u filename when both of the ``m3u`` and ``m3u_multi`` options are enabled. :bug:`1490` - :doc:`/plugins/duplicates`: Avoid a crash when misconfigured. :bug:`1457` - :doc:`/plugins/mpdstats`: Avoid a crash when the music played is not in the beets library. Thanks to :user:`CodyReichert`. :bug:`1443` - Fix a crash with ArtResizer on Windows systems (affecting :doc:`/plugins/embedart`, :doc:`/plugins/fetchart`, and :doc:`/plugins/thumbnails`). :bug:`1448` - :doc:`/plugins/permissions`: Fix an error with non-ASCII paths. :bug:`1449` - Fix sorting by paths when the :ref:`sort_case_insensitive` option is enabled. :bug:`1451` - :doc:`/plugins/embedart`: Avoid an error when trying to embed invalid images into MPEG-4 files. - :doc:`/plugins/fetchart`: The Wikipedia source can now better deal artists that use non-standard capitalization (e.g., alt-J, dEUS). - :doc:`/plugins/web`: Fix searching for non-ASCII queries. Thanks to :user:`oldtopman`. :bug:`1470` - :doc:`/plugins/mpdupdate`: We now recommend the newer ``python-mpd2`` library instead of its unmaintained parent. Thanks to :user:`Somasis`. :bug:`1472` - The importer interface and log file now output a useful list of files (instead of the word "None") when in album-grouping mode. :bug:`1475` :bug:`825` - Fix some logging errors when filenames and other user-provided strings contain curly braces. :bug:`1481` - Regular expression queries over paths now work more reliably with non-ASCII characters in filenames. :bug:`1482` - Fix a bug where the autotagger's :ref:`ignored` setting was sometimes, well, ignored. :bug:`1487` - Fix a bug with Unicode strings when generating image thumbnails. :bug:`1485` - :doc:`/plugins/keyfinder`: Fix handling of Unicode paths. :bug:`1502` - :doc:`/plugins/fetchart`: When album art is already present, the message is now printed in the ``text_highlight_minor`` color (light gray). Thanks to :user:`Somasis`. :bug:`1512` - Some messages in the console UI now use plural nouns correctly. Thanks to :user:`JesseWeinstein`. :bug:`1521` - Sorting numerical fields (such as track) now works again. :bug:`1511` - :doc:`/plugins/replaygain`: Missing GStreamer plugins now cause a helpful error message instead of a crash. :bug:`1518` - Fix an edge case when producing sanitized filenames where the maximum path length conflicted with the :ref:`replace` rules. Thanks to Ben Ockmore. :bug:`496` :bug:`1361` - Fix an incompatibility with OS X 10.11 (where ``/usr/sbin`` seems not to be on the user's path by default). - Fix an incompatibility with certain JPEG files. Here's a relevant `Python bug`_. Thanks to :user:`nathdwek`. :bug:`1545` - Fix the :ref:`group_albums` importer mode so that it works correctly when files are not already in order by album. :bug:`1550` - The ``fields`` command no longer separates built-in fields from plugin-provided ones. This distinction was becoming increasingly unreliable. - :doc:`/plugins/duplicates`: Fix a Unicode warning when paths contained non-ASCII characters. :bug:`1551` - :doc:`/plugins/fetchart`: Work around a urllib3 bug that could cause a crash. :bug:`1555` :bug:`1556` - When you edit the configuration file with ``beet config -e`` and the file does not exist, beets creates an empty file before editing it. This fixes an error on OS X, where the ``open`` command does not work with non-existent files. :bug:`1480` - :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows under Python 3. :bug:`2515` :bug:`2516` .. _ipfs: https://about.ipfs.io/ .. _python bug: https://bugs.python.org/issue16512 1.3.13 (April 24, 2015) ----------------------- This is a tiny bug-fix release. It copes with a dependency upgrade that broke beets. There are just two fixes: - Fix compatibility with Jellyfish_ version 0.5.0. - :doc:`/plugins/embedart`: In ``auto`` mode (the import hook), the plugin now respects the ``write`` config option under ``import``. If this is disabled, album art is no longer embedded on import in order to leave files untouched---in effect, ``auto`` is implicitly disabled. :bug:`1427` 1.3.12 (April 18, 2015) ----------------------- This little update makes queries more powerful, sorts music more intelligently, and removes a performance bottleneck. There's an experimental new plugin for synchronizing metadata with music players. Packagers should also note a new dependency in this version: the Jellyfish_ Python library makes our text comparisons (a big part of the auto-tagging process) go much faster. New features ~~~~~~~~~~~~ - Queries can now use **"or" logic**: if you use a comma to separate parts of a query, items and albums will match *either* side of the comma. For example, ``beet ls foo , bar`` will get all the items matching ``foo`` or matching ``bar``. See :ref:`combiningqueries`. :bug:`1423` - The autotagger's **matching algorithm is faster**. We now use the Jellyfish_ library to compute string similarity, which is better optimized than our hand-rolled edit distance implementation. :bug:`1389` - Sorting is now **case insensitive** by default. This means that artists will be sorted lexicographically regardless of case. For example, the artist alt-J will now properly sort before YACHT. (Previously, it would have ended up at the end of the list, after all the capital-letter artists.) You can turn this new behavior off using the :ref:`sort_case_insensitive` configuration option. See :ref:`query-sort`. :bug:`1429` - An experimental new :doc:`/plugins/metasync` lets you get metadata from your favorite music players, starting with Amarok. :bug:`1386` - :doc:`/plugins/fetchart`: There are new settings to control what constitutes "acceptable" images. The ``minwidth`` option constrains the minimum image width in pixels and the ``enforce_ratio`` option requires that images be square. :bug:`1394` Little fixes and improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/fetchart`: Remove a hard size limit when fetching from the Cover Art Archive. - The output of the :ref:`fields-cmd` command is now sorted. Thanks to :user:`multikatt`. :bug:`1402` - :doc:`/plugins/replaygain`: Fix a number of issues with the new ``bs1770gain`` backend on Windows. Also, fix missing debug output in import mode. :bug:`1398` - Beets should now be better at guessing the appropriate output encoding on Windows. (Specifically, the console output encoding is guessed separately from the encoding for command-line arguments.) A bug was also fixed where beets would ignore the locale settings and use UTF-8 by default. :bug:`1419` - :doc:`/plugins/discogs`: Better error handling when we can't communicate with Discogs on setup. :bug:`1417` - :doc:`/plugins/importadded`: Fix a crash when importing singletons in-place. :bug:`1416` - :doc:`/plugins/fuzzy`: Fix a regression causing a crash in the last release. :bug:`1422` - Fix a crash when the importer cannot open its log file. Thanks to :user:`barsanuphe`. :bug:`1426` - Fix an error when trying to write tags for items with flexible fields called ``date`` and ``original_date`` (which are not built-in beets fields). :bug:`1404` .. _jellyfish: https://github.com/jamesturk/jellyfish 1.3.11 (April 5, 2015) ---------------------- In this release, we refactored the logging system to be more flexible and more useful. There are more granular levels of verbosity, the output from plugins should be more consistent, and several kinds of logging bugs should be impossible in the future. There are also two new plugins: one for filtering the files you import and an evolved plugin for using album art as directory thumbnails in file managers. There's a new source for album art, and the importer now records the source of match data. This is a particularly huge release---there's lots more below. There's one big change with this release: **Python 2.6 is no longer supported**. You'll need Python 2.7. Please trust us when we say this let us remove a surprising number of ugly hacks throughout the code. Major new features and bigger changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - There are now **multiple levels of output verbosity**. On the command line, you can make beets somewhat verbose with ``-v`` or very verbose with ``-vv``. For the importer especially, this makes the first verbose mode much more manageable, while still preserving an option for overwhelmingly verbose debug output. :bug:`1244` - A new :doc:`/plugins/filefilter` lets you write regular expressions to automatically **avoid importing** certain files. Thanks to :user:`mried`. :bug:`1186` - A new :doc:`/plugins/thumbnails` generates cover-art **thumbnails for album folders** for Freedesktop.org-compliant file managers. (This replaces the :doc:`/plugins/freedesktop`, which only worked with the Dolphin file manager.) - :doc:`/plugins/replaygain`: There is a new backend that uses the bs1770gain_ analysis tool. Thanks to :user:`jmwatte`. :bug:`1343` - A new ``filesize`` field on items indicates the number of bytes in the file. :bug:`1291` - A new :conf:`plugins.index:search_limit` configuration option allows you to specify how many search results you wish to see when looking up releases at MusicBrainz during import. :bug:`1245` - The importer now records the data source for a match in a new flexible attribute ``data_source`` on items and albums. :bug:`1311` - The colors used in the terminal interface are now configurable via the new config option ``colors``, nested under the option ``ui``. (Also, the ``color`` config option has been moved from top-level to under ``ui``. Beets will respect the old color setting, but will warn the user with a deprecation message.) :bug:`1238` - :doc:`/plugins/fetchart`: There's a new Wikipedia image source that uses DBpedia to find albums. Thanks to Tom Jaspers. :bug:`1194` - In the :ref:`config-cmd` command, the output is now redacted by default. Sensitive information like passwords and API keys is not included. The new ``--clear`` option disables redaction. :bug:`1376` You should probably also know about these core changes to the way beets works ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - As mentioned above, Python 2.6 is no longer supported. - The ``tracktotal`` attribute is now a *track-level field* instead of an album-level one. This field stores the total number of tracks on the album, or if the :ref:`per_disc_numbering` config option is set, the total number of tracks on a particular medium (i.e., disc). The field was causing problems with that :ref:`per_disc_numbering` mode: different discs on the same album needed different track totals. The field can now work correctly in either mode. - To replace ``tracktotal`` as an album-level field, there is a new ``albumtotal`` computed attribute that provides the total number of tracks on the album. (The :ref:`per_disc_numbering` option has no influence on this field.) - The ``list_format_album`` and ``list_format_item`` configuration keys now affect (almost) every place where objects are printed and logged. (Previously, they only controlled the :ref:`list-cmd` command and a few other scattered pieces.) :bug:`1269` - Relatedly, the ``beet`` program now accept top-level options ``--format-item`` and ``--format-album`` before any subcommand to control how items and albums are displayed. :bug:`1271` - ``list_format_album`` and ``list_format_album`` have respectively been renamed :ref:`format_album` and :ref:`format_item`. The old names still work but each triggers a warning message. :bug:`1271` - :ref:`Path queries <pathquery>` are automatically triggered only if the path targeted by the query exists. Previously, just having a slash somewhere in the query was enough, so ``beet ls AC/DC`` wouldn't work to refer to the artist. There are also lots of medium-sized features in this update ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/duplicates`: The command has a new ``--strict`` option that will only report duplicates if all attributes are explicitly set. :bug:`1000` - :doc:`/plugins/smartplaylist`: Playlist updating should now be faster: the plugin detects, for each playlist, whether it needs to be regenerated, instead of obliviously regenerating all of them. The ``splupdate`` command can now also take additional parameters that indicate the names of the playlists to regenerate. - :doc:`/plugins/play`: The command shows the output of the underlying player command and lets you interact with it. :bug:`1321` - The summary shown to compare duplicate albums during import now displays the old and new filesizes. :bug:`1291` - :doc:`/plugins/lastgenre`: Add *comedy*, *humor*, and *stand-up* as well as a longer list of classical music genre tags to the built-in whitelist and canonicalization tree. :bug:`1206` :bug:`1239` :bug:`1240` - :doc:`/plugins/web`: Add support for *cross-origin resource sharing* for more flexible in-browser clients. Thanks to Andre Miller. :bug:`1236` :bug:`1237` - :doc:`plugins/mbsync`: A new ``-f/--format`` option controls the output format when listing unrecognized items. The output is also now more helpful by default. :bug:`1246` - :doc:`/plugins/fetchart`: A new option, ``-n``, extracts the cover art of all matched albums into their respective directories. Another new flag, ``-a``, associates the extracted files with the albums in the database. :bug:`1261` - :doc:`/plugins/info`: A new option, ``-i``, can display only a specified subset of properties. :bug:`1287` - The number of missing/unmatched tracks is shown during import. :bug:`1088` - :doc:`/plugins/permissions`: The plugin now also adjusts the permissions of the directories. (Previously, it only affected files.) :bug:`1308` :bug:`1324` - :doc:`/plugins/ftintitle`: You can now configure the format that the plugin uses to add the artist to the title. Thanks to :user:`amishb`. :bug:`1377` And many little fixes and improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/replaygain`: Stop applying replaygain directly to source files when using the mp3gain backend. :bug:`1316` - Path queries are case-sensitive on non-Windows OSes. :bug:`1165` - :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new MusixMatch backend. :bug:`1204` - Fix a crash when ``beet`` is invoked without arguments. :bug:`1205` :bug:`1207` - :doc:`/plugins/fetchart`: Do not attempt to import directories as album art. :bug:`1177` :bug:`1211` - :doc:`/plugins/mpdstats`: Avoid double-counting some play events. :bug:`773` :bug:`1212` - Fix a crash when the importer deals with Unicode metadata in ``--pretend`` mode. :bug:`1214` - :doc:`/plugins/smartplaylist`: Fix ``album_query`` so that individual files are added to the playlist instead of directories. :bug:`1225` - Remove the ``beatport`` plugin. Beatport_ has shut off public access to their API and denied our request for an account. We have not heard from the company since 2013, so we are assuming access will not be restored. - Incremental imports now (once again) show a "skipped N directories" message. - :doc:`/plugins/embedart`: Handle errors in ImageMagick's output. :bug:`1241` - :doc:`/plugins/keyfinder`: Parse the underlying tool's output more robustly. :bug:`1248` - :doc:`/plugins/embedart`: We now show a comprehensible error message when ``beet embedart -f FILE`` is given a non-existent path. :bug:`1252` - Fix a crash when a file has an unrecognized image type tag. Thanks to Matthias Kiefer. :bug:`1260` - :doc:`/plugins/importfeeds` and :doc:`/plugins/smartplaylist`: Automatically create parent directories for playlist files (instead of crashing when the parent directory does not exist). :bug:`1266` - The :ref:`write-cmd` command no longer tries to "write" non-writable fields, such as the bitrate. :bug:`1268` - The error message when MusicBrainz is not reachable on the network is now much clearer. Thanks to Tom Jaspers. :bug:`1190` :bug:`1272` - Improve error messages when parsing query strings with shlex. :bug:`1290` - :doc:`/plugins/embedart`: Fix a crash that occurred when used together with the *check* plugin. :bug:`1241` - :doc:`/plugins/scrub`: Log an error instead of stopping when the ``beet scrub`` command cannot write a file. Also, avoid problems on Windows with Unicode filenames. :bug:`1297` - :doc:`/plugins/discogs`: Handle and log more kinds of communication errors. :bug:`1299` :bug:`1305` - :doc:`/plugins/lastgenre`: Bugs in the ``pylast`` library can no longer crash beets. - :doc:`/plugins/convert`: You can now configure the temporary directory for conversions. Thanks to :user:`autochthe`. :bug:`1382` :bug:`1383` - :doc:`/plugins/rewrite`: Fix a regression that prevented the plugin's rewriting from applying to album-level fields like ``$albumartist``. :bug:`1393` - :doc:`/plugins/play`: The plugin now sorts items according to the configuration in album mode. - :doc:`/plugins/fetchart`: The name for extracted art files is taken from the ``art_filename`` configuration option. :bug:`1258` - When there's a parse error in a query (for example, when you type a malformed date in a :ref:`date query <datequery>`), beets now stops with an error instead of silently ignoring the query component. - :doc:`/plugins/smartplaylist`: Stream-friendly smart playlists. The ``splupdate`` command can now also add a URL-encodable prefix to every path in the playlist file. For developers ~~~~~~~~~~~~~~ - The ``database_change`` event now sends the item or album that is subject to a change. - The ``OptionParser`` is now a ``CommonOptionsParser`` that offers facilities for adding usual options (``--album``, ``--path`` and ``--format``). See :ref:`add_subcommands`. :bug:`1271` - The logging system in beets has been overhauled. Plugins now each have their own logger, which helps by automatically adjusting the verbosity level in import mode and by prefixing the plugin's name. Logging levels are dynamically set when a plugin is called, depending on how it is called (import stage, event or direct command). Finally, logging calls can (and should!) use modern ``{}``-style string formatting lazily. See :ref:`plugin-logging` in the plugin API docs. - A new ``import_task_created`` event lets you manipulate import tasks immediately after they are initialized. It's also possible to replace the originally created tasks by returning new ones using this event. .. _bs1770gain: https://sourceforge.net/projects/bs1770gain/ 1.3.10 (January 5, 2015) ------------------------ This version adds a healthy helping of new features and fixes a critical MPEG-4--related bug. There are more lyrics sources, there new plugins for managing permissions and integrating with Plex_, and the importer has a new ``--pretend`` flag that shows which music *would* be imported. One backwards-compatibility note: the :doc:`/plugins/lyrics` now requires the requests_ library. If you use this plugin, you will need to install the library by typing ``pip install requests`` or the equivalent for your OS. Also, as an advance warning, this will be one of the last releases to support Python 2.6. If you have a system that cannot run Python 2.7, please consider upgrading soon. The new features are ~~~~~~~~~~~~~~~~~~~~ - A new :doc:`/plugins/permissions` makes it easy to fix permissions on music files as they are imported. Thanks to :user:`xsteadfastx`. :bug:`1098` - A new :doc:`/plugins/plexupdate` lets you notify a Plex_ server when the database changes. Thanks again to xsteadfastx. :bug:`1120` - The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the files that will be imported. Thanks to :user:`mried`. :bug:`1162` - :doc:`/plugins/lyrics`: Add Musixmatch_ source and introduce a new ``sources`` config option that lets you choose exactly where to look for lyrics and in which order. - :doc:`/plugins/lyrics`: Add Brazilian and Spanish sources to Google custom search engine. - Add a warning when importing a directory that contains no music. :bug:`1116` :bug:`1127` - :doc:`/plugins/zero`: Can now remove embedded images. :bug:`1129` :bug:`1100` - The :ref:`config-cmd` command can now be used to edit the configuration even when it has syntax errors. :bug:`1123` :bug:`1128` - :doc:`/plugins/lyrics`: Added a new ``force`` config option. :bug:`1150` As usual, there are loads of little fixes and improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix a new crash with the latest version of Mutagen (1.26). - :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google backed by merging text blocks separated by empty ``<div>`` tags before scraping. - We now print a better error message when the database file is corrupted. - :doc:`/plugins/discogs`: Only prompt for authentication when running the :ref:`import-cmd` command. :bug:`1123` - When deleting fields with the :ref:`modify-cmd` command, do not crash when the field cannot be removed (i.e., when it does not exist, when it is a built-in field, or when it is a computed field). :bug:`1124` - The deprecated ``echonest_tempo`` plugin has been removed. Please use the ``echonest`` plugin instead. - ``echonest`` plugin: Fingerprint-based lookup has been removed in accordance with `API changes`_. :bug:`1121` - ``echonest`` plugin: Avoid a crash when the song has no duration information. :bug:`896` - :doc:`/plugins/lyrics`: Avoid a crash when retrieving non-ASCII lyrics from the Google backend. :bug:`1135` :bug:`1136` - :doc:`/plugins/smartplaylist`: Sort specifiers are now respected in queries. Thanks to :user:`djl`. :bug:`1138` :bug:`1137` - :doc:`/plugins/ftintitle` and :doc:`/plugins/lyrics`: Featuring artists can now be detected when they use the Spanish word *con*. :bug:`1060` :bug:`1143` - :doc:`/plugins/mbcollection`: Fix an "HTTP 400" error caused by a change in the MusicBrainz API. :bug:`1152` - The ``%`` and ``_`` characters in path queries do not invoke their special SQL meaning anymore. :bug:`1146` - :doc:`/plugins/convert`: Command-line argument construction now works on Windows. Thanks to :user:`mluds`. :bug:`1026` :bug:`1157` :bug:`1158` - :doc:`/plugins/embedart`: Fix an erroneous missing-art error on Windows. Thanks to :user:`mluds`. :bug:`1163` - :doc:`/plugins/importadded`: Now works with in-place and symlinked imports. :bug:`1170` - :doc:`/plugins/ftintitle`: The plugin is now quiet when it runs as part of the import process. Thanks to :user:`Freso`. :bug:`1176` :bug:`1172` - :doc:`/plugins/ftintitle`: Fix weird behavior when the same artist appears twice in the artist string. Thanks to Marc Addeo. :bug:`1179` :bug:`1181` - :doc:`/plugins/lastgenre`: Match songs more robustly when they contain dashes. Thanks to :user:`djl`. :bug:`1156` - The :ref:`config-cmd` command can now use ``$EDITOR`` variables with arguments. .. _api changes: https://web.archive.org/web/20160814092627/https://developer.echonest.com/forums/thread/3650 .. _musixmatch: https://www.musixmatch.com/ .. _plex: https://watch.plex.tv/ 1.3.9 (November 17, 2014) ------------------------- This release adds two new standard plugins to beets: one for synchronizing Last.fm listening data and one for integrating with Linux desktops. And at long last, imports can now create symbolic links to music files instead of copying or moving them. We also gained the ability to search for album art on the iTunes Store and a new way to compute ReplayGain levels. The major new features are ~~~~~~~~~~~~~~~~~~~~~~~~~~ - A new :doc:`/plugins/lastimport` lets you download your play count data from Last.fm into a flexible attribute. Thanks to Rafael Bodill. - A new :doc:`/plugins/freedesktop` creates metadata files for Freedesktop.org--compliant file managers. Thanks to :user:`kerobaros`. :bug:`1056`, :bug:`707` - A new :ref:`link` option in the ``import`` section creates symbolic links during import instead of moving or copying. Thanks to Rovanion Luckey. :bug:`710`, :bug:`114` - :doc:`/plugins/fetchart`: You can now search for art on the iTunes Store. There's also a new ``sources`` config option that lets you choose exactly where to look for images and in which order. - :doc:`/plugins/replaygain`: A new Python Audio Tools backend was added. Thanks to Francesco Rubino. :bug:`1070` - :doc:`/plugins/embedart`: You can now automatically check that new art looks similar to existing art---ensuring that you only get a better "version" of the art you already have. See :ref:`image-similarity-check`. - :doc:`/plugins/ftintitle`: The plugin now runs automatically on import. To disable this, unset the ``auto`` config flag. There are also core improvements and other substantial additions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The ``media`` attribute is now a *track-level field* instead of an album-level one. This field stores the delivery mechanism for the music, so in its album-level incarnation, it could not represent heterogeneous releases---for example, an album consisting of a CD and a DVD. Now, tracks accurately indicate the media they appear on. Thanks to Heinz Wiesinger. - Re-imports of your existing music (see :ref:`reimport`) now preserve its added date and flexible attributes. Thanks to Stig Inge Lea Bjørnsen. - Slow queries, such as those over flexible attributes, should now be much faster when used with certain commands---notably, the :doc:`/plugins/play`. - :doc:`/plugins/bpd`: Add a new configuration option for setting the default volume. Thanks to IndiGit. - :doc:`/plugins/embedart`: A new ``ifempty`` config option lets you only embed album art when no album art is present. Thanks to kerobaros. - :doc:`/plugins/discogs`: Authenticate with the Discogs server. The plugin now requires a Discogs account due to new API restrictions. Thanks to :user:`multikatt`. :bug:`1027`, :bug:`1040` And countless little improvements and fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Standard cover art in APEv2 metadata is now supported. Thanks to Matthias Kiefer. :bug:`1042` - :doc:`/plugins/convert`: Avoid a crash when embedding cover art fails. - :doc:`/plugins/mpdstats`: Fix an error on start (introduced in the previous version). Thanks to Zach Denton. - :doc:`/plugins/convert`: The ``--yes`` command-line flag no longer expects an argument. - :doc:`/plugins/play`: Remove the temporary .m3u file after sending it to the player. - The importer no longer tries to highlight partial differences in numeric quantities (track numbers and durations), which was often confusing. - Date-based queries that are malformed (not parse-able) no longer crash beets and instead fail silently. - :doc:`/plugins/duplicates`: Emit an error when the ``checksum`` config option is set incorrectly. - The migration from pre-1.1, non-YAML configuration files has been removed. If you need to upgrade an old config file, use an older version of beets temporarily. - :doc:`/plugins/discogs`: Recover from HTTP errors when communicating with the Discogs servers. Thanks to Dustin Rodriguez. - :doc:`/plugins/embedart`: Do not log "embedding album art into..." messages during the import process. - Fix a crash in the autotagger when files had only whitespace in their metadata. - :doc:`/plugins/play`: Fix a potential crash when the command outputs special characters. :bug:`1041` - :doc:`/plugins/web`: Queries typed into the search field are now treated as separate query components. :bug:`1045` - Date tags that use slashes instead of dashes as separators are now interpreted correctly. And WMA (ASF) files now map the ``comments`` field to the "Description" tag (in addition to "WM/Comments"). Thanks to Matthias Kiefer. :bug:`1043` - :doc:`/plugins/embedart`: Avoid resizing the image multiple times when embedding into an album. Thanks to :user:`kerobaros`. :bug:`1028`, :bug:`1036` - :doc:`/plugins/discogs`: Avoid a situation where a trailing comma could be appended to some artist names. :bug:`1049` - The output of the :ref:`stats-cmd` command is slightly different: the approximate size is now marked as such, and the total number of seconds only appears in exact mode. - :doc:`/plugins/convert`: A new ``copy_album_art`` option puts images alongside converted files. Thanks to Ángel Alonso. :bug:`1050`, :bug:`1055` - There is no longer a "conflict" between two plugins that declare the same field with the same type. Thanks to Peter Schnebel. :bug:`1059` :bug:`1061` - :doc:`/plugins/chroma`: Limit the number of releases and recordings fetched as the result of an Acoustid match to avoid extremely long processing times for very popular music. :bug:`1068` - Fix an issue where modifying an album's field without actually changing it would not update the corresponding tracks to bring differing tracks back in line with the album. :bug:`856` - ``echonest`` plugin: When communicating with the Echo Nest servers fails repeatedly, log an error instead of exiting. :bug:`1096` - :doc:`/plugins/lyrics`: Avoid an error when the Google source returns a result without a title. Thanks to Alberto Leal. :bug:`1097` - Importing an archive will no longer leave temporary files behind in ``/tmp``. Thanks to :user:`multikatt`. :bug:`1067`, :bug:`1091` 1.3.8 (September 17, 2014) -------------------------- This release has two big new chunks of functionality. Queries now support **sorting** and user-defined fields can now have **types**. If you want to see all your songs in reverse chronological order, just type ``beet list year-``. It couldn't be easier. For details, see :ref:`query-sort`. Flexible field types mean that some functionality that has previously only worked for built-in fields, like range queries, can now work with plugin- and user-defined fields too. For starters, the ``echonest`` plugin and :doc:`/plugins/mpdstats` now mark the types of the fields they provide---so you can now say, for example, ``beet ls liveness:0.5..1.5`` for the Echo Nest "liveness" attribute. The :doc:`/plugins/types` makes it easy to specify field types in your config file. One upgrade note: if you use the :doc:`/plugins/discogs`, you will need to upgrade the Discogs client library to use this version. Just type ``pip install -U discogs-client``. Other new features ~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/info`: Target files can now be specified through library queries (in addition to filenames). The ``--library`` option prints library fields instead of tags. Multiple files can be summarized together with the new ``--summarize`` option. - :doc:`/plugins/mbcollection`: A new option lets you automatically update your collection on import. Thanks to Olin Gay. - :doc:`/plugins/convert`: A new ``never_convert_lossy_files`` option can prevent lossy transcoding. Thanks to Simon Kohlmeyer. - :doc:`/plugins/convert`: A new ``--yes`` command-line flag skips the confirmation. Still more fixes and little improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Invalid state files don't crash the importer. - :doc:`/plugins/lyrics`: Only strip featured artists and parenthesized title suffixes if no lyrics for the original artist and title were found. - Fix a crash when reading some files with missing tags. - :doc:`/plugins/discogs`: Compatibility with the new 2.0 version of the discogs_client_ Python library. If you were using the old version, you will need to upgrade to the latest version of the library to use the correspondingly new version of the plugin (e.g., with ``pip install -U discogs-client``). Thanks to Andriy Kohut. - Fix a crash when writing files that can't be read. Thanks to Jocelyn De La Rosa. - The :ref:`stats-cmd` command now counts album artists. The album count also more accurately reflects the number of albums in the database. - :doc:`/plugins/convert`: Avoid crashes when tags cannot be written to newly converted files. - Formatting templates with item data no longer confusingly shows album-level data when the two are inconsistent. - Resuming imports and beginning incremental imports should now be much faster when there is a lot of previously-imported music to skip. - :doc:`/plugins/lyrics`: Remove ``<script>`` tags from scraped lyrics. Thanks to Bombardment. - :doc:`/plugins/play`: Add a ``relative_to`` config option. Thanks to BrainDamage. - Fix a crash when a MusicBrainz release has zero tracks. - The ``--version`` flag now works as an alias for the ``version`` command. - :doc:`/plugins/lastgenre`: Remove some unhelpful genres from the default whitelist. Thanks to gwern. - :doc:`/plugins/importfeeds`: A new ``echo`` output mode prints files' paths to standard error. Thanks to robotanarchy. - :doc:`/plugins/replaygain`: Restore some error handling when ``mp3gain`` output cannot be parsed. The verbose log now contains the bad tool output in this case. - :doc:`/plugins/convert`: Fix filename extensions when converting automatically. - The ``write`` plugin event allows plugins to change the tags that are written to a media file. - :doc:`/plugins/zero`: Do not delete database values; only media file tags are affected. .. _discogs_client: https://github.com/discogs/discogs_client 1.3.7 (August 22, 2014) ----------------------- This release of beets fixes all the bugs, and you can be confident that you will never again find any bugs in beets, ever. It also adds support for plain old AIFF files and adds three more plugins, including a nifty one that lets you measure a song's tempo by tapping out the beat on your keyboard. The importer deals more elegantly with duplicates and you can broaden your cover art search to the entire web with Google Image Search. The big new features are ~~~~~~~~~~~~~~~~~~~~~~~~ - Support for AIFF files. Tags are stored as ID3 frames in one of the file's IFF chunks. Thanks to Evan Purkhiser for contributing support to Mutagen_. - The new :doc:`/plugins/importadded` reads files' modification times to set their "added" date. Thanks to Stig Inge Lea Bjørnsen. - The new :doc:`/plugins/bpm` lets you manually measure the tempo of a playing song. Thanks to aroquen. - The new :doc:`/plugins/spotify` generates playlists for your Spotify_ account. Thanks to Olin Gay. - A new :ref:`required` configuration option for the importer skips matches that are missing certain data. Thanks to oprietop. - When the importer detects duplicates, it now shows you some details about the potentially-replaced music so you can make an informed decision. Thanks to Howard Jones. - :doc:`/plugins/fetchart`: You can now optionally search for cover art on Google Image Search. Thanks to Lemutar. - A new :ref:`asciify-paths` configuration option replaces all non-ASCII characters in paths. .. _mutagen: https://github.com/quodlibet/mutagen .. _spotify: https://open.spotify.com/ And the multitude of little improvements and fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Compatibility with the latest version of Mutagen_, 1.23. - :doc:`/plugins/web`: Lyrics now display readably with correct line breaks. Also, the detail view scrolls to reveal all of the lyrics. Thanks to Meet Udeshi. - :doc:`/plugins/play`: The ``command`` config option can now contain arguments (rather than just an executable). Thanks to Alessandro Ghedini. - Fix an error when using the :ref:`modify-cmd` command to remove a flexible attribute. Thanks to Pierre Rust. - :doc:`/plugins/info`: The command now shows audio properties (e.g., bitrate) in addition to metadata. Thanks Alessandro Ghedini. - Avoid a crash on Windows when writing to files with special characters in their names. - :doc:`/plugins/play`: Playing albums now generates filenames by default (as opposed to directories) for better compatibility. The ``use_folders`` option restores the old behavior. Thanks to Lucas Duailibe. - Fix an error when importing an empty directory with the ``--flat`` option. - :doc:`/plugins/mpdstats`: The last song in a playlist is now correctly counted as played. Thanks to Johann Klähn. - :doc:`/plugins/zero`: Prevent accidental nulling of dangerous fields (IDs and paths). Thanks to brunal. - The :ref:`remove-cmd` command now shows the paths of files that will be deleted. Thanks again to brunal. - Don't display changes for fields that are not in the restricted field set. This fixes :ref:`write-cmd` showing changes for fields that are not written to the file. - The :ref:`write-cmd` command avoids displaying the item name if there are no changes for it. - When using both the :doc:`/plugins/convert` and the :doc:`/plugins/scrub`, avoid scrubbing the source file of conversions. (Fix a regression introduced in the previous release.) - :doc:`/plugins/replaygain`: Logging is now quieter during import. Thanks to Yevgeny Bezman. - :doc:`/plugins/fetchart`: When loading art from the filesystem, we now prioritize covers with more keywords in them. This means that ``cover-front.jpg`` will now be taken before ``cover-back.jpg`` because it contains two keywords rather than one. Thanks to Fabrice Laporte. - :doc:`/plugins/lastgenre`: Remove duplicates from canonicalized genre lists. Thanks again to Fabrice Laporte. - The importer now records its progress when skipping albums. This means that incremental imports will no longer try to import albums again after you've chosen to skip them, and erroneous invitations to resume "interrupted" imports should be reduced. Thanks to jcassette. - :doc:`/plugins/bucket`: You can now customize the definition of alphanumeric "ranges" using regular expressions. And the heuristic for detecting years has been improved. Thanks to sotho. - Already-imported singleton tracks are skipped when resuming an import. - :doc:`/plugins/chroma`: A new ``auto`` configuration option disables fingerprinting on import. Thanks to ddettrittus. - :doc:`/plugins/convert`: A new ``--format`` option to can select the transcoding preset from the command-line. - :doc:`/plugins/convert`: Transcoding presets can now omit their filename extensions (extensions default to the name of the preset). - :doc:`/plugins/convert`: A new ``--pretend`` option lets you preview the commands the plugin will execute without actually taking any action. Thanks to Dietrich Daroch. - Fix a crash when a float-valued tag field only contained a ``+`` or ``-`` character. - Fixed a regression in the core that caused the :doc:`/plugins/scrub` not to work in ``auto`` mode. Thanks to Harry Khanna. - The :ref:`write-cmd` command now has a ``--force`` flag. Thanks again to Harry Khanna. - :doc:`/plugins/mbsync`: Track alignment now works with albums that have multiple copies of the same recording. Thanks to Rui Gonçalves. 1.3.6 (May 10, 2014) -------------------- This is primarily a bugfix release, but it also brings two new plugins: one for playing music in desktop players and another for organizing your directories into "buckets." It also brings huge performance optimizations to queries---your ``beet ls`` commands will now go much faster. New features ~~~~~~~~~~~~ - The new :doc:`/plugins/play` lets you start your desktop music player with the songs that match a query. Thanks to David Hamp-Gonsalves. - The new :doc:`/plugins/bucket` provides a ``%bucket{}`` function for path formatting to generate folder names representing ranges of years or initial letter. Thanks to Fabrice Laporte. - Item and album queries are much faster. - :doc:`/plugins/ftintitle`: A new option lets you remove featured artists entirely instead of moving them to the title. Thanks to SUTJael. And those all-important bug fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/mbsync`: Fix a regression in 1.3.5 that broke the plugin entirely. - :ref:`Shell completion <completion>` now searches more common paths for its ``bash_completion`` dependency. - Fix encoding-related logging errors in :doc:`/plugins/convert` and :doc:`/plugins/replaygain`. - :doc:`/plugins/replaygain`: Suppress a deprecation warning emitted by later versions of PyGI. - Fix a crash when reading files whose iTunes SoundCheck tags contain non-ASCII characters. - The ``%if{}`` template function now appropriately interprets the condition as false when it contains the string "false". Thanks to Ayberk Yilmaz. - :doc:`/plugins/convert`: Fix conversion for files that include a video stream by ignoring it. Thanks to brunal. - :doc:`/plugins/fetchart`: Log an error instead of crashing when tag manipulation fails. - :doc:`/plugins/convert`: Log an error instead of crashing when embedding album art fails. - :doc:`/plugins/convert`: Embed cover art into converted files. Previously they were embedded into the source files. - New plugin event: ``before_item_moved``. Thanks to Robert Speicher. 1.3.5 (April 15, 2014) ---------------------- This is a short-term release that adds some great new stuff to beets. There's support for tracking and calculating musical keys, the ReplayGain plugin was expanded to work with more music formats via GStreamer, we can now import directly from compressed archives, and the lyrics plugin is more robust. One note for upgraders and packagers: this version of beets has a new dependency in enum34_, which is a backport of the new enum_ standard library module. The major new features are ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Beets can now import ``zip``, ``tar``, and ``rar`` archives. Just type ``beet import music.zip`` to have beets transparently extract the files to import. - :doc:`/plugins/replaygain`: Added support for calculating ReplayGain values with GStreamer as well the mp3gain program. This enables ReplayGain calculation for any audio format. Thanks to Yevgeny Bezman. - :doc:`/plugins/lyrics`: Lyrics should now be found for more songs. Searching is now sensitive to featured artists and parenthesized title suffixes. When a song has multiple titles, lyrics from all the named songs are now concatenated. Thanks to Fabrice Laporte and Paul Phillips. In particular, a full complement of features for supporting musical keys are new in this release: - A new ``initial_key`` field is available in the database and files' tags. You can set the field manually using a command like ``beet modify initial_key=Am``. - The ``echonest`` plugin sets the ``initial_key`` field if the data is available. - A new :doc:`/plugins/keyfinder` runs a command-line tool to get the key from audio data and store it in the ``initial_key`` field. There are also many bug fixes and little enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ``echonest`` plugin: Truncate files larger than 50MB before uploading for analysis. - :doc:`/plugins/fetchart`: Fix a crash when the server does not specify a content type. Thanks to Lee Reinhardt. - :doc:`/plugins/convert`: The ``--keep-new`` flag now works correctly and the library includes the converted item. - The importer now logs a message instead of crashing when errors occur while opening the files to be imported. - :doc:`/plugins/embedart`: Better error messages in exceptional conditions. - Silenced some confusing error messages when searching for a non-MusicBrainz ID. Using an invalid ID (of any kind---Discogs IDs can be used there too) at the "Enter ID:" importer prompt now just silently returns no results. More info is in the verbose logs. - :doc:`/plugins/mbsync`: Fix application of album-level metadata. Due to a regression a few releases ago, only track-level metadata was being updated. - On Windows, paths on network shares (UNC paths) no longer cause "invalid filename" errors. - :doc:`/plugins/replaygain`: Fix crashes when attempting to log errors. - The :ref:`modify-cmd` command can now accept query arguments that contain = signs. An argument is considered a query part when a : appears before any =s. Thanks to mook. .. _enum: https://docs.python.org/3.4/library/enum.html .. _enum34: https://pypi.org/project/enum34/ 1.3.4 (April 5, 2014) --------------------- This release brings a hodgepodge of medium-sized conveniences to beets. A new :ref:`config-cmd` command manages your configuration, we now have :ref:`bash completion <completion>`, and the :ref:`modify-cmd` command can delete attributes. There are also some significant performance optimizations to the autotagger's matching logic. One note for upgraders: if you use the :doc:`/plugins/fetchart`, it has a new dependency, the requests_ module. New stuff ~~~~~~~~~ - Added a :ref:`config-cmd` command to manage your configuration. It can show you what you currently have in your config file, point you at where the file should be, or launch your text editor to let you modify the file. Thanks to geigerzaehler. - Beets now ships with a shell command completion script! See :ref:`completion`. Thanks to geigerzaehler. - The :ref:`modify-cmd` command now allows removing flexible attributes. For example, ``beet modify artist:beatles oldies!`` deletes the ``oldies`` attribute from matching items. Thanks to brilnius. - Internally, beets has laid the groundwork for supporting multi-valued fields. Thanks to geigerzaehler. - The importer interface now shows the URL for MusicBrainz matches. Thanks to johtso. - :doc:`/plugins/smartplaylist`: Playlists can now be generated from multiple queries (combined with "or" logic). Album-level queries are also now possible and automatic playlist regeneration can now be disabled. Thanks to brilnius. - ``echonest`` plugin: Echo Nest similarity now weights the tempo in better proportion to other metrics. Also, options were added to specify custom thresholds and output formats. Thanks to Adam M. - Added the :ref:`after_write <plugin_events>` plugin event. - :doc:`/plugins/lastgenre`: Separator in genre lists can now be configured. Thanks to brilnius. - We now only use "primary" aliases for artist names from MusicBrainz. This eliminates some strange naming that could occur when the ``languages`` config option was set. Thanks to Filipe Fortes. - The performance of the autotagger's matching mechanism is vastly improved. This should be noticeable when matching against very large releases such as box sets. - The :ref:`import-cmd` command can now accept individual files as arguments even in non-singleton mode. Files are imported as one-track albums. Fixes ~~~~~ - Error messages involving paths no longer escape non-ASCII characters (for legibility). - Fixed a regression that made it impossible to use the :ref:`modify-cmd` command to add new flexible fields. Thanks to brilnius. - ``echonest`` plugin: Avoid crashing when the audio analysis fails. Thanks to Pedro Silva. - :doc:`/plugins/duplicates`: Fix checksumming command execution for files with quotation marks in their names. Thanks again to Pedro Silva. - Fix a crash when importing with both of the :ref:`group_albums` and :ref:`incremental` options enabled. Thanks to geigerzaehler. - Give a sensible error message when ``BEETSDIR`` points to a file. Thanks again to geigerzaehler. - Fix a crash when reading WMA files whose boolean-valued fields contain strings. Thanks to johtso. - :doc:`/plugins/fetchart`: The plugin now sends "beets" as the User-Agent when making scraping requests. This helps resolve some blocked requests. The plugin now also depends on the requests_ Python library. - The :ref:`write-cmd` command now only shows the changes to fields that will actually be written to a file. - :doc:`/plugins/duplicates`: Spurious reports are now avoided for tracks with missing values (e.g., no MBIDs). Thanks to Pedro Silva. - The default :ref:`replace` sanitation options now remove leading whitespace by default. Thanks to brilnius. - :doc:`/plugins/importfeeds`: Fix crash when importing albums containing ``/`` with the ``m3u_multi`` format. - Avoid crashing on Mutagen bugs while writing files' tags. - :doc:`/plugins/convert`: Display a useful error message when the FFmpeg executable can't be found. .. _requests: https://requests.readthedocs.io/en/latest/ 1.3.3 (February 26, 2014) ------------------------- Version 1.3.3 brings a bunch changes to how item and album fields work internally. Along with laying the groundwork for some great things in the future, this brings a number of improvements to how you interact with beets. Here's what's new with fields in particular: - Plugin-provided fields can now be used in queries. For example, if you use the :doc:`/plugins/inline` to define a field called ``era``, you can now filter your library based on that field by typing something like ``beet list era:goldenage``. - Album-level flexible attributes and plugin-provided attributes can now be used in path formats (and other item-level templates). - :ref:`Date-based queries <datequery>` are now possible. Try getting every track you added in February 2014 with ``beet ls added:2014-02`` or in the whole decade with ``added:2010..``. Thanks to Stig Inge Lea Bjørnsen. - The :ref:`modify-cmd` command is now better at parsing and formatting fields. You can assign to boolean fields like ``comp``, for example, using either the words "true" or "false" or the numerals 1 and 0. Any boolean-esque value is normalized to a real boolean. The :ref:`update-cmd` and :ref:`write-cmd` commands also got smarter at formatting and colorizing changes. For developers, the short version of the story is that Item and Album objects provide *uniform access* across fixed, flexible, and computed attributes. You can write ``item.foo`` to access the ``foo`` field without worrying about where the data comes from. Unrelated new stuff ~~~~~~~~~~~~~~~~~~~ - The importer has a new interactive option (*G* for "Group albums"), command-line flag (``--group-albums``), and config option (:ref:`group_albums`) that lets you split apart albums that are mixed together in a single directory. Thanks to geigerzaehler. - A new ``--config`` command-line option lets you specify an additional configuration file. This option *combines* config settings with your default config file. (As part of this change, the ``BEETSDIR`` environment variable no longer combines---it *replaces* your default config file.) Thanks again to geigerzaehler. - :doc:`/plugins/ihate`: The plugin's configuration interface was overhauled. Its configuration is now much simpler---it uses beets queries instead of an ad-hoc per-field configuration. This is *backwards-incompatible*---if you use this plugin, you will need to update your configuration. Thanks to BrainDamage. Other little fixes ~~~~~~~~~~~~~~~~~~ - ``echonest`` plugin: Tempo (BPM) is now always stored as an integer. Thanks to Heinz Wiesinger. - Fix Python 2.6 compatibility in some logging statements in :doc:`/plugins/chroma` and :doc:`/plugins/lastgenre`. - Prevent some crashes when things go really wrong when writing file metadata at the end of the import process. - New plugin events: ``item_removed`` (thanks to Romuald Conty) and ``item_copied`` (thanks to Stig Inge Lea Bjørnsen). - The ``pluginpath`` config option can now point to the directory containing plugin code. (Previously, it awkwardly needed to point at a directory containing a ``beetsplug`` directory, which would then contain your code. This is preserved as an option for backwards compatibility.) This change should also work around a long-standing issue when using ``pluginpath`` when beets is installed using pip. Many thanks to geigerzaehler. - :doc:`/plugins/web`: The ``/item/`` and ``/album/`` API endpoints now produce full details about albums and items, not just lists of IDs. Thanks to geigerzaehler. - Fix a potential crash when using image resizing with the :doc:`/plugins/fetchart` or :doc:`/plugins/embedart` without ImageMagick installed. - Also, when invoking ``convert`` for image resizing fails, we now log an error instead of crashing. - :doc:`/plugins/fetchart`: The ``beet fetchart`` command can now associate local images with albums (unless ``--force`` is provided). Thanks to brilnius. - :doc:`/plugins/fetchart`: Command output is now colorized. Thanks again to brilnius. - The :ref:`modify-cmd` command avoids writing files and committing to the database when nothing has changed. Thanks once more to brilnius. - The importer now uses the album artist field when guessing existing metadata for albums (rather than just the track artist field). Thanks to geigerzaehler. - :doc:`/plugins/fromfilename`: Fix a crash when a filename contained only a track number (e.g., ``02.mp3``). - :doc:`/plugins/convert`: Transcoding should now work on Windows. - :doc:`/plugins/duplicates`: The ``move`` and ``copy`` destination arguments are now treated as directories. Thanks to Pedro Silva. - The :ref:`modify-cmd` command now skips confirmation and prints a message if no changes are necessary. Thanks to brilnius. - :doc:`/plugins/fetchart`: When using the ``remote_priority`` config option, local image files are no longer completely ignored. - ``echonest`` plugin: Fix an issue causing the plugin to appear twice in the output of the ``beet version`` command. - :doc:`/plugins/lastgenre`: Fix an occasional crash when no tag weight was returned by Last.fm. - :doc:`/plugins/mpdstats`: Restore the ``last_played`` field. Thanks to Johann Klähn. - The :ref:`modify-cmd` command's output now clearly shows when a file has been deleted. - Album art in files with Vorbis Comments is now marked with the "front cover" type. Thanks to Jason Lefley. 1.3.2 (December 22, 2013) ------------------------- This update brings new plugins for fetching acoustic metrics and listening statistics, many more options for the duplicate detection plugin, and flexible options for fetching multiple genres. The "core" of beets gained a new built-in command: :ref:`beet write <write-cmd>` updates the metadata tags for files, bringing them back into sync with your database. Thanks to Heinz Wiesinger. We added some plugins and overhauled some existing ones ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The new ``echonest`` plugin plugin can fetch a wide range of `acoustic attributes`_ from `The Echo Nest`_, including the "speechiness" and "liveness" of each track. The new plugin supersedes an older version (``echonest_tempo``) that only fetched the BPM field. Thanks to Pedro Silva and Peter Schnebel. - The :doc:`/plugins/duplicates` got a number of new features, thanks to Pedro Silva: - The ``keys`` option lets you specify the fields used detect duplicates. - You can now use checksumming (via an external command) to find duplicates instead of metadata via the ``checksum`` option. - The plugin can perform actions on the duplicates it find. The new ``copy``, ``move``, ``delete``, ``delete_file``, and ``tag`` options perform those actions. - The new :doc:`/plugins/mpdstats` collects statistics about your listening habits from MPD_. Thanks to Peter Schnebel and Johann Klähn. - :doc:`/plugins/lastgenre`: The new ``multiple`` option has been replaced with the ``count`` option, which lets you limit the number of genres added to your music. (No more thousand-character genre fields!) Also, the ``min_weight`` field filters out nonsense tags to make your genres more relevant. Thanks to Peter Schnebel and rashley60. - :doc:`/plugins/lyrics`: A new ``--force`` option optionally re-downloads lyrics even when files already have them. Thanks to Bitdemon. As usual, there are also innumerable little fixes and improvements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - When writing ID3 tags for ReplayGain normalization, tags are written with both upper-case and lower-case TXXX frame descriptions. Previous versions of beets used only the upper-case style, which seems to be more standard, but some players (namely, Quod Libet and foobar2000) seem to only use lower-case names. - :doc:`/plugins/missing`: Avoid a possible error when an album's ``tracktotal`` field is missing. - :doc:`/plugins/ftintitle`: Fix an error when the sort artist is missing. - ``echonest_tempo``: The plugin should now match songs more reliably (i.e., fewer "no tempo found" messages). Thanks to Peter Schnebel. - :doc:`/plugins/convert`: Fix an "Item has no library" error when using the ``auto`` config option. - :doc:`/plugins/convert`: Fix an issue where files of the wrong format would have their transcoding skipped (and files with the right format would be needlessly transcoded). Thanks to Jakob Schnitzer. - Fix an issue that caused the :ref:`id3v23` option to work only occasionally. - Also fix using :ref:`id3v23` in conjunction with the ``scrub`` and ``embedart`` plugins. Thanks to Chris Cogburn. - :doc:`/plugins/ihate`: Fix an error when importing singletons. Thanks to Mathijs de Bruin. - The :ref:`clutter` option can now be a whitespace-separated list in addition to a YAML list. - Values for the :ref:`replace` option can now be empty (i.e., null is equivalent to the empty string). - :doc:`/plugins/lastgenre`: Fix a conflict between canonicalization and multiple genres. - When a match has a year but not a month or day, the autotagger now "zeros out" the month and day fields after applying the year. - For plugin developers: added an ``optparse`` callback utility function for performing actions based on arguments. Thanks to Pedro Silva. - :doc:`/plugins/scrub`: Fix scrubbing of MPEG-4 files. Thanks to Yevgeny Bezman. .. _acoustic attributes: https://web.archive.org/web/20160701063109/http://developer.echonest.com/acoustic-attributes.html .. _mpd: https://www.musicpd.org/ 1.3.1 (October 12, 2013) ------------------------ This release boasts a host of new little features, many of them contributed by beets' amazing and prolific community. It adds support for Opus_ files, transcoding to any format, and two new plugins: one that guesses metadata for "blank" files based on their filenames and one that moves featured artists into the title field. Here's the new stuff ~~~~~~~~~~~~~~~~~~~~ - Add Opus_ audio support. Thanks to Rowan Lewis. - :doc:`/plugins/convert`: You can now transcode files to any audio format, rather than just MP3. Thanks again to Rowan Lewis. - The new :doc:`/plugins/fromfilename` guesses tags from the filenames during import when metadata tags themselves are missing. Thanks to Jan-Erik Dahlin. - The :doc:`/plugins/ftintitle`, by `@Verrus`_, is now distributed with beets. It helps you rewrite tags to move "featured" artists from the artist field to the title field. - The MusicBrainz data source now uses track artists over recording artists. This leads to better metadata when tagging classical music. Thanks to Henrique Ferreiro. - :doc:`/plugins/lastgenre`: You can now get multiple genres per album or track using the ``multiple`` config option. Thanks to rashley60 on GitHub. - A new :ref:`id3v23` config option makes beets write MP3 files' tags using the older ID3v2.3 metadata standard. Use this if you want your tags to be visible to Windows and some older players. And some fixes ~~~~~~~~~~~~~~ - :doc:`/plugins/fetchart`: Better error message when the image file has an unrecognized type. - :doc:`/plugins/mbcollection`: Detect, log, and skip invalid MusicBrainz IDs (instead of failing with an API error). - :doc:`/plugins/info`: Fail gracefully when used erroneously with a directory. - ``echonest_tempo``: Fix an issue where the plugin could use the tempo from the wrong song when the API did not contain the requested song. - Fix a crash when a file's metadata included a very large number (one wider than 64 bits). These huge numbers are now replaced with zeroes in the database. - When a track on a MusicBrainz release has a different length from the underlying recording's length, the track length is now used instead. - With :ref:`per_disc_numbering` enabled, the ``tracktotal`` field is now set correctly (i.e., to the number of tracks on the disc). - :doc:`/plugins/scrub`: The ``scrub`` command now restores album art in addition to other (database-backed) tags. - :doc:`/plugins/mpdupdate`: Domain sockets can now begin with a tilde (which is correctly expanded to ``$HOME``) as well as a slash. Thanks to Johann Klähn. - :doc:`/plugins/lastgenre`: Fix a regression that could cause new genres found during import not to be persisted. - Fixed a crash when imported album art was also marked as "clutter" where the art would be deleted before it could be moved into place. This led to a "image.jpg not found during copy" error. Now clutter is removed (and directories pruned) much later in the process, after the ``import_task_files`` hook. - :doc:`/plugins/missing`: Fix an error when printing missing track names. Thanks to Pedro Silva. - Fix an occasional KeyError in the :ref:`update-cmd` command introduced in 1.3.0. - :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such as NCON. .. _@verrus: https://github.com/Verrus .. _opus: https://www.opus-codec.org/ 1.3.0 (September 11, 2013) -------------------------- Albums and items now have **flexible attributes**. This means that, when you want to store information about your music in the beets database, you're no longer constrained to the set of fields it supports out of the box (title, artist, track, etc.). Instead, you can use any field name you can think of and treat it just like the built-in fields. For example, you can use the :ref:`modify-cmd` command to set a new field on a track: :: $ beet modify mood=sexy artist:miguel and then query your music based on that field: :: $ beet ls mood:sunny or use templates to see the value of the field: :: $ beet ls -f '$title: $mood' While this feature is nifty when used directly with the usual command-line suspects, it's especially useful for plugin authors and for future beets features. Stay tuned for great things built on this flexible attribute infrastructure. One side effect of this change: queries that include unknown fields will now match *nothing* instead of *everything*. So if you type ``beet ls fieldThatDoesNotExist:foo``, beets will now return no results, whereas previous versions would spit out a warning and then list your entire library. There's more detail than you could ever need `on the beets blog`_. .. _on the beets blog: https://beets.io/blog/flexattr.html 1.2.2 (August 27, 2013) ----------------------- This is a bugfix release. We're in the midst of preparing for a large change in beets 1.3, so 1.2.2 resolves some issues that came up over the last few weeks. Stay tuned! The improvements in this release are ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - A new plugin event, ``item_moved``, is sent when files are moved on disk. Thanks to dsedivec. - :doc:`/plugins/lyrics`: More improvements to the Google backend by Fabrice Laporte. - :doc:`/plugins/bpd`: Fix for a crash when searching, thanks to Simon Chopin. - Regular expression queries (and other query types) over paths now work. (Previously, special query types were ignored for the ``path`` field.) - :doc:`/plugins/fetchart`: Look for images in the Cover Art Archive for the release group in addition to the specific release. Thanks to Filipe Fortes. - Fix a race in the importer that could cause files to be deleted before they were imported. This happened when importing one album, importing a duplicate album, and then asking for the first album to be replaced with the second. The situation could only arise when importing music from the library directory and when the two albums are imported close in time. 1.2.1 (June 22, 2013) --------------------- This release introduces a major internal change in the way that similarity scores are handled. It means that the importer interface can now show you exactly why a match is assigned its score and that the autotagger gained a few new options that let you customize how matches are prioritized and recommended. The refactoring work is due to the continued efforts of Tai Lee. The changes you'll notice while using the autotagger are: - The top 3 distance penalties are now displayed on the release listing, and all album and track penalties are now displayed on the track changes list. This should make it clear exactly which metadata is contributing to a low similarity score. - When displaying differences, the colorization has been made more consistent and helpful: red for an actual difference, yellow to indicate that a distance penalty is being applied, and light gray for no penalty (e.g., case changes) or disambiguation data. There are also three new (or overhauled) configuration options that let you customize the way that matches are selected: - The :ref:`ignored` setting lets you instruct the importer not to show you matches that have a certain penalty applied. - The :ref:`preferred` collection of settings specifies a sorted list of preferred countries and media types, or prioritizes releases closest to the original year for an album. - The :ref:`max_rec` settings can now be used for any distance penalty component. The recommendation will be downgraded if a non-zero penalty is being applied to the specified field. And some little enhancements and bug fixes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Multi-disc directory names can now contain "disk" (in addition to "disc"). Thanks to John Hawthorn. - :doc:`/plugins/web`: Item and album counts are now exposed through the API for use with the Tomahawk resolver. Thanks to Uwe L. Korn. - Python 2.6 compatibility for ``beatport``, :doc:`/plugins/missing`, and :doc:`/plugins/duplicates`. Thanks to Wesley Bitter and Pedro Silva. - Don't move the config file during a null migration. Thanks to Theofilos Intzoglou. - Fix an occasional crash in the ``beatport`` when a length field was missing from the API response. Thanks to Timothy Appnel. - :doc:`/plugins/scrub`: Handle and log I/O errors. - :doc:`/plugins/lyrics`: The Google backend should now turn up more results. Thanks to Fabrice Laporte. - :doc:`/plugins/random`: Fix compatibility with Python 2.6. Thanks to Matthias Drochner. 1.2.0 (June 5, 2013) -------------------- There's a *lot* of new stuff in this release: new data sources for the autotagger, new plugins to look for problems in your library, tracking the date that you acquired new music, an awesome new syntax for doing queries over numeric fields, support for ALAC files, and major enhancements to the importer's UI and distance calculations. A special thanks goes out to all the contributors who helped make this release awesome. For the first time, beets can now tag your music using additional **data sources** to augment the matches from MusicBrainz. When you enable either of these plugins, the importer will start showing you new kinds of matches: - New :doc:`/plugins/discogs`: Get matches from the Discogs_ database. Thanks to Artem Ponomarenko and Tai Lee. - New ``beatport`` plugin: Get matches from the Beatport_ database. Thanks to Johannes Baiter. We also have two other new plugins that can scan your library to check for common problems, both by Pedro Silva: - New :doc:`/plugins/duplicates`: Find tracks or albums in your library that are **duplicated**. - New :doc:`/plugins/missing`: Find albums in your library that are **missing tracks**. There are also three more big features added to beets core ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Your library now keeps track of **when music was added** to it. The new ``added`` field is a timestamp reflecting when each item and album was imported and the new ``%time{}`` template function lets you format this timestamp for humans. Thanks to Lucas Duailibe. - When using queries to match on quantitative fields, you can now use **numeric ranges**. For example, you can get a list of albums from the '90s by typing ``beet ls year:1990..1999`` or find high-bitrate music with ``bitrate:128000..``. See :ref:`numericquery`. Thanks to Michael Schuerig. - **ALAC files** are now marked as ALAC instead of being conflated with AAC audio. Thanks to Simon Luijk. In addition, the importer saw various UI enhancements, thanks to Tai Lee ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - More consistent format and colorization of album and track metadata. - Display data source URL for matches from the new data source plugins. This should make it easier to migrate data from Discogs or Beatport into MusicBrainz. - Display album disambiguation and disc titles in the track listing, when available. - Track changes are highlighted in yellow when they indicate a change in format to or from the style of :ref:`per_disc_numbering`. (As before, no penalty is applied because the track number is still "correct", just in a different format.) - Sort missing and unmatched tracks by index and title and group them together for better readability. - Indicate MusicBrainz ID mismatches. The calculation of the similarity score for autotagger matches was also improved, again thanks to Tai Lee. These changes, in general, help deal with the new metadata sources and help disambiguate between similar releases in the same MusicBrainz release group: - Strongly prefer releases with a matching MusicBrainz album ID. This helps beets re-identify the same release when re-importing existing files. - Prefer releases that are closest to the tagged ``year``. Tolerate files tagged with release or original year. - The new ``preferred_media`` config option lets you prefer a certain media type when the ``media`` field is unset on an album. - Apply minor penalties across a range of fields to differentiate between nearly identical releases: ``disctotal``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. As usual, there were also lots of other great littler enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each artist in your collection to avoid biasing random samples to prolific artists. Thanks to Georges Dubus. - The :ref:`modify-cmd` now correctly converts types when modifying non-string fields. You can now safely modify the "comp" flag and the "year" field, for example. Thanks to Lucas Duailibe. - :doc:`/plugins/convert`: You can now configure the path formats for converted files separately from your main library. Thanks again to Lucas Duailibe. - The importer output now shows the number of audio files in each album. Thanks to jayme on GitHub. - Plugins can now provide fields for both Album and Item templates, thanks to Pedro Silva. Accordingly, the :doc:`/plugins/inline` can also now define album fields. For consistency, the ``pathfields`` configuration section has been renamed ``item_fields`` (although the old name will still work for compatibility). - Plugins can also provide metadata matches for ID searches. For example, the new Discogs plugin lets you search for an album by its Discogs ID from the same prompt that previously just accepted MusicBrainz IDs. Thanks to Johannes Baiter. - The :ref:`fields-cmd` command shows template fields provided by plugins. Thanks again to Pedro Silva. - :doc:`/plugins/mpdupdate`: You can now communicate with MPD over a Unix domain socket. Thanks to John Hawthorn. And a batch of fixes ~~~~~~~~~~~~~~~~~~~~ - Album art filenames now respect the :ref:`replace` configuration. - Friendly error messages are now printed when trying to read or write files that go missing. - The :ref:`modify-cmd` command can now change albums' album art paths (i.e., ``beet modify artpath=...`` works). Thanks to Lucas Duailibe. - :doc:`/plugins/zero`: Fix a crash when nulling out a field that contains None. - Templates can now refer to non-tag item fields (e.g., ``$id`` and ``$album_id``). - :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due to some fixes in dealing with special characters. .. _beatport: https://www.beatport.com/ .. _discogs: https://discogs.com/ 1.1.0 (April 29, 2013) ---------------------- This final release of 1.1 brings a little polish to the betas that introduced the new configuration system. The album art and lyrics plugins also got a little love. If you're upgrading from 1.0.0 or earlier, this release (like the 1.1 betas) will automatically migrate your configuration to the new system. - :doc:`/plugins/embedart`: The ``embedart`` command now embeds each album's associated art by default. The ``--file`` option invokes the old behavior, in which a specific image file is used. - :doc:`/plugins/lyrics`: A new (optional) Google Custom Search backend was added for finding lyrics on a wide array of sites. Thanks to Fabrice Laporte. - When automatically detecting the filesystem's maximum filename length, never guess more than 200 characters. This prevents errors on systems where the maximum length was misreported. You can, of course, override this default with the :ref:`max_filename_length` option. - :doc:`/plugins/fetchart`: Two new configuration options were added: ``cover_names``, the list of keywords used to identify preferred images, and ``cautious``, which lets you avoid falling back to images that don't contain those keywords. Thanks to Fabrice Laporte. - Avoid some error cases in the ``update`` command and the ``embedart`` and ``mbsync`` plugins. Invalid or missing files now cause error logs instead of crashing beets. Thanks to Lucas Duailibe. - :doc:`/plugins/lyrics`: Searches now strip "featuring" artists when searching for lyrics, which should increase the hit rate for these tracks. Thanks to Fabrice Laporte. - When listing the items in an album, the items are now always in track-number order. This should lead to more predictable listings from the :doc:`/plugins/importfeeds`. - :doc:`/plugins/smartplaylist`: Queries are now split using shell-like syntax instead of just whitespace, so you can now construct terms that contain spaces. - :doc:`/plugins/lastgenre`: The ``force`` config option now defaults to true and controls the behavior of the import hook. (Previously, new genres were always forced during import.) - :doc:`/plugins/web`: Fix an error when specifying the hostname on the command line. - :doc:`/plugins/web`: The underlying API was expanded slightly to support Tomahawk_ collections. And file transfers now have a "Content-Length" header. Thanks to Uwe L. Korn. - :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization. .. _tomahawk: https://github.com/tomahawk-player/tomahawk 1.1b3 (March 16, 2013) ---------------------- This third beta of beets 1.1 brings a hodgepodge of little new features (and internal overhauls that will make improvements easier in the future). There are new options for getting metadata in a particular language and seeing more detail during the import process. There's also a new plugin for synchronizing your metadata with MusicBrainz. Under the hood, plugins can now extend the query syntax. New configuration options ~~~~~~~~~~~~~~~~~~~~~~~~~ - :ref:`languages` controls the preferred languages when selecting an alias from MusicBrainz. This feature requires python-musicbrainzngs_ 0.3 or later. Thanks to Sam Doshi. - :ref:`detail` enables a mode where all tracks are listed in the importer UI, as opposed to only changed tracks. - The ``--flat`` option to the ``beet import`` command treats an entire directory tree of music files as a single album. This can help in situations where a multi-disc album is split across multiple directories. - :doc:`/plugins/importfeeds`: An option was added to use absolute, rather than relative, paths. Thanks to Lucas Duailibe. Other stuff ~~~~~~~~~~~ - A new :doc:`/plugins/mbsync` provides a command that looks up each item and track in MusicBrainz and updates your library to reflect it. This can help you easily correct errors that have been fixed in the MB database. Thanks to Jakob Schnitzer. - :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a new query type. To perform fuzzy searches, use the ``~`` prefix with :ref:`list-cmd` or other commands. Thanks to Philippe Mongeau. - As part of the above, plugins can now extend the query syntax and new kinds of matching capabilities to beets. See :ref:`extend-query`. Thanks again to Philippe Mongeau. - :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store transcoded files in your library while backing up the originals (instead of vice-versa). Thanks to Lucas Duailibe. - :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode audio files automatically during import. Thanks again to Lucas Duailibe. - :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and store fingerprints for items that don't yet have them. One more round of applause for Lucas Duailibe. - ``echonest_tempo``: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. - When the importer encounters an error (insufficient permissions, for example) when walking a directory tree, it now logs an error instead of crashing. - In path formats, null database values now expand to the empty string instead of the string "None". - Add "System Volume Information" (an internal directory found on some Windows filesystems) to the default ignore list. - Fix a crash when ReplayGain values were set to null. - Fix a crash when iTunes Sound Check tags contained invalid data. - Fix an error when the configuration file (``config.yaml``) is completely empty. - Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to Sam Doshi. - :doc:`/plugins/convert`: Fix a bug when creating files with Unicode pathnames. - Fix a spurious warning from the Unidecode module when matching albums that are missing all metadata. - Fix Unicode errors when a directory or file doesn't exist when invoking the import command. Thanks to Lucas Duailibe. - :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when MusicBrainz exceptions occur. - ``echonest_tempo``: Catch socket errors that are not handled by the Echo Nest library. - :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting fingerprints. 1.1b2 (February 16, 2013) ------------------------- The second beta of beets 1.1 uses the fancy new configuration infrastructure to add many, many new config options. The import process is more flexible; filenames can be customized in more detail; and more. This release also supports Windows Media (ASF) files and iTunes Sound Check volume normalization. This version introduces one **change to the default behavior** that you should be aware of. Previously, when importing new albums matched in MusicBrainz, the date fields (``year``, ``month``, and ``day``) would be set to the release date of the *original* version of the album, as opposed to the specific date of the release selected. Now, these fields reflect the specific release and ``original_year``, etc., reflect the earlier release date. If you want the old behavior, just set :ref:`original_date` to true in your config file. New configuration options ~~~~~~~~~~~~~~~~~~~~~~~~~ - :ref:`default_action` lets you determine the default (just-hit-return) option is when considering a candidate. - :ref:`none_rec_action` lets you skip the prompt, and automatically choose an action, when there is no good candidate. Thanks to Tai Lee. - :ref:`max_rec` lets you define a maximum recommendation for albums with missing/extra tracks or differing track lengths/numbers. Thanks again to Tai Lee. - :ref:`original_date` determines whether, when importing new albums, the ``year``, ``month``, and ``day`` fields should reflect the specific (e.g., reissue) release date or the original release date. Note that the original release date is always available as ``original_year``, etc. - :ref:`clutter` controls which files should be ignored when cleaning up empty directories. Thanks to Steinþór Pálsson. - :doc:`/plugins/lastgenre`: A new configuration option lets you choose to retrieve artist-level tags as genres instead of album- or track-level tags. Thanks to Peter Fern and Peter Schnebel. - :ref:`max_filename_length` controls truncation of long filenames. Also, beets now tries to determine the filesystem's maximum length automatically if you leave this option unset. - :doc:`/plugins/fetchart`: The ``remote_priority`` option searches remote (Web) art sources even when local art is present. - You can now customize the character substituted for path separators (e.g., /) in filenames via ``path_sep_replace``. The default is an underscore. Use this setting with caution. Other new stuff ~~~~~~~~~~~~~~~ - Support for Windows Media/ASF audio files. Thanks to Dave Hayes. - New :doc:`/plugins/smartplaylist`: generate and maintain m3u playlist files based on beets queries. Thanks to Dang Mai Hai. - ReplayGain tags on MPEG-4/AAC files are now supported. And, even more astonishingly, ReplayGain values in MP3 and AAC files are now compatible with `iTunes Sound Check`_. Thanks to Dave Hayes. - Track titles in the importer UI's difference display are now either aligned vertically or broken across two lines for readability. Thanks to Tai Lee. - Albums and items have new fields reflecting the *original* release date (``original_year``, ``original_month``, and ``original_day``). Previously, when tagging from MusicBrainz, *only* the original date was stored; now, the old fields refer to the *specific* release date (e.g., when the album was reissued). - Some changes to the way candidates are recommended for selection, thanks to Tai Lee: - According to the new :ref:`max_rec` configuration option, partial album matches are downgraded to a "low" recommendation by default. - When a match isn't great but is either better than all the others or the only match, it is given a "low" (rather than "medium") recommendation. - There is no prompt default (i.e., input is required) when matches are bad: "low" or "none" recommendations or when choosing a candidate other than the first. - The importer's heuristic for coalescing the directories in a multi-disc album has been improved. It can now detect when two directories alongside each other share a similar prefix but a different number (e.g., "Album Disc 1" and "Album Disc 2") even when they are not alone in a common parent directory. Thanks once again to Tai Lee. - Album listings in the importer UI now show the release medium (CD, Vinyl, 3xCD, etc.) as well as the disambiguation string. Thanks to Peter Schnebel. - :doc:`/plugins/lastgenre`: The plugin can now get different genres for individual tracks on an album. Thanks to Peter Schnebel. - When getting data from MusicBrainz, the album disambiguation string (``albumdisambig``) now reflects both the release and the release group. - :doc:`/plugins/mpdupdate`: Sends an update message whenever *anything* in the database changes---not just when importing. Thanks to Dang Mai Hai. - When the importer UI shows a difference in track numbers or durations, they are now colorized based on the *suffixes* that differ. For example, when showing the difference between 2:01 and 2:09, only the last digit will be highlighted. - The importer UI no longer shows a change when the track length difference is less than 10 seconds. (This threshold was previously 2 seconds.) - Two new plugin events were added: *database_change* and *cli_exit*. Thanks again to Dang Mai Hai. - Plugins are now loaded in the order they appear in the config file. Thanks to Dang Mai Hai. - :doc:`/plugins/bpd`: Browse by album artist and album artist sort name. Thanks to Steinþór Pálsson. - ``echonest_tempo``: Don't attempt a lookup when the artist or track title is missing. - Fix an error when migrating the ``.beetsstate`` file on Windows. - A nicer error message is now given when the configuration file contains tabs. (YAML doesn't like tabs.) - Fix the ``-l`` (log path) command-line option for the ``import`` command. .. _itunes sound check: https://support.apple.com/itunes 1.1b1 (January 29, 2013) ------------------------ This release entirely revamps beets' configuration system. The configuration file is now a YAML_ document and is located, along with other support files, in a common directory (e.g., ``~/.config/beets`` on Unix-like systems). .. _yaml: https://en.wikipedia.org/wiki/YAML - Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and ``fuzzy_search`` has been renamed to ``fuzzy``. - Renamed config options: Many plugins have a flag dictating whether their action runs at import time. This option had many names (``autofetch``, ``autoembed``, etc.) but is now consistently called ``auto``. - Reorganized import config options: The various ``import_*`` options are now organized under an ``import:`` heading and their prefixes have been removed. - New default file locations: The default filename of the library database is now ``library.db`` in the same directory as the config file, as opposed to ``~/.beetsmusic.blb`` previously. Similarly, the runtime state file is now called ``state.pickle`` in the same directory instead of ``~/.beetsstate``. It also adds some new features ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - :doc:`/plugins/inline`: Inline definitions can now contain statements or blocks in addition to just expressions. Thanks to Florent Thoumie. - Add a configuration option, :ref:`terminal_encoding`, controlling the text encoding used to print messages to standard output. - The MusicBrainz hostname (and rate limiting) are now configurable. See :ref:`musicbrainz-config`. - You can now configure the similarity thresholds used to determine when the autotagger automatically accepts a metadata match. See :ref:`match-config`. - :doc:`/plugins/importfeeds`: Added a new configuration option that controls the base for relative paths used in m3u files. Thanks to Philippe Mongeau. 1.0.0 (January 29, 2013) ------------------------ After fifteen betas and two release candidates, beets has finally hit one-point-oh. Congratulations to everybody involved. This version of beets will remain stable and receive only bug fixes from here on out. New development is ongoing in the betas of version 1.1. - :doc:`/plugins/scrub`: Fix an incompatibility with Python 2.6. - :doc:`/plugins/lyrics`: Fix an issue that failed to find lyrics when metadata contained "real" apostrophes. - :doc:`/plugins/replaygain`: On Windows, emit a warning instead of crashing when analyzing non-ASCII filenames. - Silence a spurious warning from version 0.04.12 of the Unidecode module. 1.0rc2 (December 31, 2012) -------------------------- This second release candidate follows quickly after rc1 and fixes a few small bugs found since that release. There were a couple of regressions and some bugs in a newly added plugin. - ``echonest_tempo``: If the Echo Nest API limit is exceeded or a communication error occurs, the plugin now waits and tries again instead of crashing. Thanks to Zach Denton. - :doc:`/plugins/fetchart`: Fix a regression that caused crashes when art was not available from some sources. - Fix a regression on Windows that caused all relative paths to be "not found". 1.0rc1 (December 17, 2012) -------------------------- The first release candidate for beets 1.0 includes a deluge of new features contributed by beets users. The vast majority of the credit for this release goes to the growing and vibrant beets community. A million thanks to everybody who contributed to this release. There are new plugins for transcoding music, fuzzy searches, tempo collection, and fiddling with metadata. The ReplayGain plugin has been rebuilt from scratch. Album art images can now be resized automatically. Many other smaller refinements make things "just work" as smoothly as possible. With this release candidate, beets 1.0 is feature-complete. We'll be fixing bugs on the road to 1.0 but no new features will be added. Concurrently, work begins today on features for version 1.1. - New plugin: :doc:`/plugins/convert` **transcodes** music and embeds album art while copying to a separate directory. Thanks to Jakob Schnitzer and Andrew G. Dunn. - New plugin: :doc:`/plugins/fuzzy` lets you find albums and tracks using **fuzzy string matching** so you don't have to type (or even remember) their exact names. Thanks to Philippe Mongeau. - New plugin: ``echonest_tempo`` fetches **tempo** (BPM) information from `The Echo Nest`_. Thanks to David Brenner. - New plugin: :doc:`/plugins/the` adds a template function that helps format text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr. - New plugin: :doc:`/plugins/zero` **filters out undesirable fields** before they are written to your tags. Thanks again to Blemjhoo Tezoulbr. - New plugin: :doc:`/plugins/ihate` automatically skips (or warns you about) importing albums that match certain criteria. Thanks once again to Blemjhoo Tezoulbr. - :doc:`/plugins/replaygain`: This plugin has been completely overhauled to use the mp3gain_ or aacgain_ command-line tools instead of the failure-prone Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte. - :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`: Both plugins can now **resize album art** to avoid excessively large images. Use the ``maxwidth`` config option with either plugin. Thanks to Fabrice Laporte. - :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file rather than just one. For example, if your FLAC file has both ordinary FLAC tags and ID3 tags, the ID3 tags are now also removed. - :ref:`stats-cmd` command: New ``--exact`` switch to make the file size calculation more accurate (thanks to Jakob Schnitzer). - :ref:`list-cmd` command: Templates given with ``-f`` can now show items' and albums' paths (using ``$path``). - The output of the :ref:`update-cmd`, :ref:`remove-cmd`, and :ref:`modify-cmd` commands now respects the :ref:`list_format_album` and :ref:`list_format_item` config options. Thanks to Mike Kazantsev. - The :ref:`art-filename` option can now be a template rather than a simple string. Thanks to Jarrod Beardwood. - Fix album queries for ``artpath`` and other non-item fields. - Null values in the database can now be matched with the empty-string regular expression, ``^$``. - Queries now correctly match non-string values in path format predicates. - When autotagging a various-artists album, the album artist field is now used instead of the majority track artist. - :doc:`/plugins/lastgenre`: Use the albums' existing genre tags if they pass the whitelist (thanks to Fabrice Laporte). - :doc:`/plugins/lastgenre`: Add a ``lastgenre`` command for fetching genres post facto (thanks to Jakob Schnitzer). - :doc:`/plugins/fetchart`: Local image filenames are now used in alphabetical order. - :doc:`/plugins/fetchart`: Fix a bug where cover art filenames could lack a ``.jpg`` extension. - :doc:`/plugins/lyrics`: Fix an exception with non-ASCII lyrics. - :doc:`/plugins/web`: The API now reports file sizes (for use with the `Tomahawk resolver`_). - :doc:`/plugins/web`: Files now download with a reasonable filename rather than just being called "file" (thanks to Zach Denton). - :doc:`/plugins/importfeeds`: Fix error in symlink mode with non-ASCII filenames. - :doc:`/plugins/mbcollection`: Fix an error when submitting a large number of releases (we now submit only 200 releases at a time instead of 350). Thanks to Jonathan Towne. - :doc:`/plugins/embedart`: Made the method for embedding art into FLAC files `standard <https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE>`_-compliant. Thanks to Daniele Sluijters. - Add the track mapping dictionary to the ``album_distance`` plugin function. - When an exception is raised while reading a file, the path of the file in question is now logged (thanks to Mike Kazantsev). - Truncate long filenames based on their *bytes* rather than their Unicode *characters*, fixing situations where encoded names could be too long. - Filename truncation now incorporates the length of the extension. - Fix an assertion failure when the MusicBrainz main database and search server disagree. - Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to modify files' tags even when they successfully change the database. - Fix a VFS bug leading to a crash in the :doc:`/plugins/bpd` when files had non-ASCII extensions. - Fix for changing date fields (like "year") with the :ref:`modify-cmd` command. - Fix a crash when input is read from a pipe without a specified encoding. - Fix some problem with identifying files on Windows with Unicode directory names in their path. - Fix a crash when Unicode queries were used with ``import -L`` re-imports. - Fix an error when fingerprinting files with Unicode filenames on Windows. - Warn instead of crashing when importing a specific file in singleton mode. - Add human-readable error messages when writing files' tags fails or when a directory can't be created. - Changed plugin loading so that modules can be imported without unintentionally loading the plugins they contain. .. _aacgain: https://github.com/dgilman/aacgain .. _mp3gain: https://sourceforge.net/projects/mp3gain/ .. _the echo nest: https://web.archive.org/web/20180329103558/http://the.echonest.com/ .. _tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html 1.0b15 (July 26, 2012) ---------------------- The fifteenth (!) beta of beets is compendium of small fixes and features, most of which represent long-standing requests. The improvements include matching albums with extra tracks, per-disc track numbering in multi-disc albums, an overhaul of the album art downloader, and robustness enhancements that should keep beets running even when things go wrong. All these smaller changes should help us focus on some larger changes coming before 1.0. Please note that this release contains one backwards-incompatible change: album art fetching, which was previously baked into the import workflow, is now encapsulated in a plugin (the :doc:`/plugins/fetchart`). If you want to continue fetching cover art for your music, enable this plugin after upgrading to beets 1.0b15. - The autotagger can now find matches for albums when you have **extra tracks** on your filesystem that aren't present in the MusicBrainz catalog. Previously, if you tried to match album with 15 audio files but the MusicBrainz entry had only 14 tracks, beets would ignore this match. Now, beets will show you matches even when they are "too short" and indicate which tracks from your disk are unmatched. - Tracks on multi-disc albums can now be **numbered per-disc** instead of per-album via the :ref:`per_disc_numbering` config option. - The default output format for the ``beet list`` command is now configurable via the :ref:`list_format_item` and :ref:`list_format_album` config options. Thanks to Fabrice Laporte. - Album **cover art fetching** is now encapsulated in the :doc:`/plugins/fetchart`. Be sure to enable this plugin if you're using this functionality. As a result of this new organization, the new plugin has gained a few new features: - "As-is" and non-autotagged imports can now have album art imported from the local filesystem (although Web repositories are still not searched in these cases). - A new command, ``beet fetchart``, allows you to download album art post-import. If you only want to fetch art manually, not automatically during import, set the new plugin's ``autofetch`` option to ``no``. - New album art sources have been added. - Errors when communicating with MusicBrainz now log an error message instead of halting the importer. - Similarly, filesystem manipulation errors now print helpful error messages instead of a messy traceback. They still interrupt beets, but they should now be easier for users to understand. Tracebacks are still available in verbose mode. - New metadata fields for `artist credits`_: ``artist_credit`` and ``albumartist_credit`` can now contain release- and recording-specific variations of the artist's name. See :ref:`itemfields`. - Revamped the way beets handles concurrent database access to avoid nondeterministic SQLite-related crashes when using the multithreaded importer. On systems where SQLite was compiled without ``usleep(3)`` support, multithreaded database access could cause an internal error (with the message "database is locked"). This release synchronizes access to the database to avoid internal SQLite contention, which should avoid this error. - Plugins can now add parallel stages to the import pipeline. See :ref:`basic-plugin-setup`. - Beets now prints out an error when you use an unrecognized field name in a query: for example, when running ``beet ls -a artist:foo`` (because ``artist`` is an item-level field). - New plugin events: - ``import_task_choice`` is called after an import task has an action assigned. - ``import_task_files`` is called after a task's file manipulation has finished (copying or moving files, writing metadata tags). - ``library_opened`` is called when beets starts up and opens the library database. - :doc:`/plugins/lastgenre`: Fixed a problem where path formats containing ``$genre`` would use the old genre instead of the newly discovered one. - Fix a crash when moving files to a Samba share. - :doc:`/plugins/mpdupdate`: Fix TypeError crash (thanks to Philippe Mongeau). - When re-importing files with ``import_copy`` enabled, only files inside the library directory are moved. Files outside the library directory are still copied. This solves a problem (introduced in 1.0b14) where beets could crash after adding files to the library but before finishing copying them; during the next import, the (external) files would be moved instead of copied. - Artist sort names are now populated correctly for multi-artist tracks and releases. (Previously, they only reflected the first artist.) - When previewing changes during import, differences in track duration are now shown as "2:50 vs. 3:10" rather than separated with ``->`` like track numbers. This should clarify that beets isn't doing anything to modify lengths. - Fix a problem with query-based path format matching where a field-qualified pattern, like ``albumtype_soundtrack``, would match everything. - :doc:`/plugins/chroma`: Fix matching with ambiguous Acoustids. Some Acoustids are identified with multiple recordings; beets now considers any associated recording a valid match. This should reduce some cases of errant track reordering when using chroma. - Fix the ID3 tag name for the catalog number field. - :doc:`/plugins/chroma`: Fix occasional crash at end of fingerprint submission and give more context to "failed fingerprint generation" errors. - Interactive prompts are sent to stdout instead of stderr. - :doc:`/plugins/embedart`: Fix crash when audio files are unreadable. - :doc:`/plugins/bpd`: Fix crash when sockets disconnect (thanks to Matteo Mecucci). - Fix an assertion failure while importing with moving enabled when the file was already at its destination. - Fix Unicode values in the ``replace`` config option (thanks to Jakob Borg). - Use a nicer error message when input is requested but stdin is closed. - Fix errors on Windows for certain Unicode characters that can't be represented in the MBCS encoding. This required a change to the way that paths are represented in the database on Windows; if you find that beets' paths are out of sync with your filesystem with this release, delete and recreate your database with ``beet import -AWC /path/to/music``. - Fix ``import`` with relative path arguments on Windows. .. _artist credits: https://wiki.musicbrainz.org/Artist_Credit 1.0b14 (May 12, 2012) --------------------- The centerpiece of this beets release is the graceful handling of similarly-named albums. It's now possible to import two albums with the same artist and title and to keep them from conflicting in the filesystem. Many other awesome new features were contributed by the beets community, including regular expression queries, artist sort names, moving files on import. There are three new plugins: random song/album selection; MusicBrainz "collection" integration; and a plugin for interoperability with other music library systems. A million thanks to the (growing) beets community for making this a huge release. - The importer now gives you **choices when duplicates are detected**. Previously, when beets found an existing album or item in your library matching the metadata on a newly-imported one, it would just skip the new music to avoid introducing duplicates into your library. Now, you have three choices: skip the new music (the previous behavior), keep both, or remove the old music. See the :ref:`guide-duplicates` section in the autotagging guide for details. - Beets can now avoid storing identically-named albums in the same directory. The new ``%aunique{}`` template function, which is included in the default path formats, ensures that Crystal Castles' albums will be placed into different directories. See :ref:`aunique` for details. - Beets queries can now use **regular expressions**. Use an additional ``:`` in your query to enable regex matching. See :ref:`regex` for the full details. Thanks to Matteo Mecucci. - Artist **sort names** are now fetched from MusicBrainz. There are two new data fields, ``artist_sort`` and ``albumartist_sort``, that contain sortable artist names like "Beatles, The". These fields are also used to sort albums and items when using the ``list`` command. Thanks to Paul Provost. - Many other **new metadata fields** were added, including ASIN, label catalog number, disc title, encoder, and MusicBrainz release group ID. For a full list of fields, see :ref:`itemfields`. - :doc:`/plugins/chroma`: A new command, ``beet submit``, will **submit fingerprints** to the Acoustid database. Submitting your library helps increase the coverage and accuracy of Acoustid fingerprinting. The Chromaprint fingerprint and Acoustid ID are also now stored for all fingerprinted tracks. This version of beets *requires* at least version 0.6 of pyacoustid_ for fingerprinting to work. - The importer can now **move files**. Previously, beets could only copy files and delete the originals, which is inefficient if the source and destination are on the same filesystem. Use the ``import_move`` configuration option and see :doc:`/reference/config` for more details. Thanks to Domen Kožar. - New :doc:`/plugins/random`: Randomly select albums and tracks from your library. Thanks to Philippe Mongeau. - The :doc:`/plugins/mbcollection` by Jeffrey Aylesworth was added to the core beets distribution. - New :doc:`/plugins/importfeeds`: Catalog imported files in ``m3u`` playlist files or as symlinks for easy importing to other systems. Thanks to Fabrice Laporte. - The ``-f`` (output format) option to the ``beet list`` command can now contain template functions as well as field references. Thanks to Steve Dougherty. - A new command ``beet fields`` displays the available metadata fields (thanks to Matteo Mecucci). - The ``import`` command now has a ``--noincremental`` or ``-I`` flag to disable incremental imports (thanks to Matteo Mecucci). - When the autotagger fails to find a match, it now displays the number of tracks on the album (to help you guess what might be going wrong) and a link to the FAQ. - The default filename character substitutions were changed to be more conservative. The Windows "reserved characters" are substituted by default even on Unix platforms (this causes less surprise when using Samba shares to store music). To customize your character substitutions, see :ref:`the replace config option <replace>`. - :doc:`/plugins/lastgenre`: Added a "fallback" option when no suitable genre can be found (thanks to Fabrice Laporte). - :doc:`/plugins/rewrite`: Unicode rewriting rules are now allowed (thanks to Nicolas Dietrich). - Filename collisions are now avoided when moving album art. - :doc:`/plugins/bpd`: Print messages to show when directory tree is being constructed. - :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the deprecated ``playbin``. - :doc:`/plugins/bpd`: Random and repeat modes are now supported (thanks to Matteo Mecucci). - :doc:`/plugins/bpd`: Listings are now sorted (thanks once again to Matteo Mecucci). - Filenames are normalized with Unicode Normal Form D (NFD) on Mac OS X and NFC on all other platforms. - Significant internal restructuring to avoid SQLite locking errors. As part of these changes, the not-very-useful "save" plugin event has been removed. .. _pyacoustid: https://github.com/beetbox/pyacoustid 1.0b13 (March 16, 2012) ----------------------- Beets 1.0b13 consists of a plethora of small but important fixes and refinements. A lyrics plugin is now included with beets; new audio properties are catalogged; the ``list`` command has been made more powerful; the autotagger is more tolerant of different tagging styles; and importing with original file deletion now cleans up after itself more thoroughly. Many, many bugs—including several crashers—were fixed. This release lays the foundation for more features to come in the next couple of releases. - The :doc:`/plugins/lyrics`, originally by `Peter Brunner`_, is revamped and included with beets, making it easy to fetch **song lyrics**. - Items now expose their audio **sample rate**, number of **channels**, and **bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of all available audio properties. Thanks to Andrew Dunn. - The ``beet list`` command now accepts a "format" argument that lets you **show specific information about each album or track**. For example, run ``beet ls -af '$album: $tracktotal' beatles`` to see how long each Beatles album is. Thanks to Philippe Mongeau. - The autotagger now tolerates tracks on multi-disc albums that are numbered per-disc. For example, if track 24 on a release is the first track on the second disc, then it is not penalized for having its track number set to 1 instead of 24. - The autotagger sets the disc number and disc total fields on autotagged albums. - The autotagger now also tolerates tracks whose track artists tags are set to "Various Artists". - Terminal colors are now supported on Windows via Colorama_ (thanks to Karl). - When previewing metadata differences, the importer now shows discrepancies in track length. - Importing with ``import_delete`` enabled now cleans up empty directories that contained deleting imported music files. - Similarly, ``import_delete`` now causes original album art imported from the disk to be deleted. - Plugin-supplied template values, such as those created by ``rewrite``, are now properly sanitized (for example, ``AC/DC`` properly becomes ``AC_DC``). - Filename extensions are now always lower-cased when copying and moving files. - The ``inline`` plugin now prints a more comprehensible error when exceptions occur in Python snippets. - The ``replace`` configuration option can now remove characters entirely (in addition to replacing them) if the special string ``<strip>`` is specified as the replacement. - New plugin API: plugins can now add fields to the MediaFile tag abstraction layer. See :ref:`basic-plugin-setup`. - A reasonable error message is now shown when the import log file cannot be opened. - The import log file is now flushed and closed properly so that it can be used to monitor import progress, even when the import crashes. - Duplicate track matches are no longer shown when autotagging singletons. - The ``chroma`` plugin now logs errors when fingerprinting fails. - The ``lastgenre`` plugin suppresses more errors when dealing with the Last.fm API. - Fix a bug in the ``rewrite`` plugin that broke the use of multiple rules for a single field. - Fix a crash with non-ASCII characters in bytestring metadata fields (e.g., MusicBrainz IDs). - Fix another crash with non-ASCII characters in the configuration paths. - Fix a divide-by-zero crash on zero-length audio files. - Fix a crash in the ``chroma`` plugin when the Acoustid database had no recording associated with a fingerprint. - Fix a crash when an autotagging with an artist or album containing "AND" or "OR" (upper case). - Fix an error in the ``rewrite`` and ``inline`` plugins when the corresponding config sections did not exist. - Fix bitrate estimation for AAC files whose headers are missing the relevant data. - Fix the ``list`` command in BPD (thanks to Simon Chopin). .. _colorama: https://pypi.org/project/colorama/ 1.0b12 (January 16, 2012) ------------------------- This release focuses on making beets' path formatting vastly more powerful. It adds a function syntax for transforming text. Via a new plugin, arbitrary Python code can also be used to define new path format fields. Each path format template can now be activated conditionally based on a query. Character set substitutions are also now configurable. In addition, beets avoids problematic filename conflicts by appending numbers to filenames that would otherwise conflict. Three new plugins (``inline``, ``scrub``, and ``rewrite``) are included in this release. - **Functions in path formats** provide a simple way to write complex file naming rules: for example, ``%upper{%left{$artist,1}}`` will insert the capitalized first letter of the track's artist. For more details, see :doc:`/reference/pathformat`. If you're interested in adding your own template functions via a plugin, see :ref:`basic-plugin-setup`. - Plugins can also now define new path *fields* in addition to functions. - The new :doc:`/plugins/inline` lets you **use Python expressions to customize path formats** by defining new fields in the config file. - The configuration can **condition path formats based on queries**. That is, you can write a path format that is only used if an item matches a given query. (This supersedes the earlier functionality that only allowed conditioning on album type; if you used this feature in a previous version, you will need to replace, for example, ``soundtrack:`` with ``albumtype_soundtrack:``.) See :ref:`path-format-config`. - **Filename substitutions are now configurable** via the ``replace`` config value. You can choose which characters you think should be allowed in your directory and music file names. See :doc:`/reference/config`. - Beets now ensures that files have **unique filenames** by appending a number to any filename that would otherwise conflict with an existing file. - The new :doc:`/plugins/scrub` can remove extraneous metadata either manually or automatically. - The new :doc:`/plugins/rewrite` can canonicalize names for path formats. - The autotagging heuristics have been tweaked in situations where the MusicBrainz database did not contain track lengths. Previously, beets penalized matches where this was the case, leading to situations where seemingly good matches would have poor similarity. This penalty has been removed. - Fix an incompatibility in BPD with libmpc (the library that powers mpc and ncmpc). - Fix a crash when importing a partial match whose first track was missing. - The ``lastgenre`` plugin now correctly writes discovered genres to imported files (when tag-writing is enabled). - Add a message when skipping directories during an incremental import. - The default ignore settings now ignore all files beginning with a dot. - Date values in path formats (``$year``, ``$month``, and ``$day``) are now appropriately zero-padded. - Removed the ``--path-format`` global flag for ``beet``. - Removed the ``lastid`` plugin, which was deprecated in the previous version. 1.0b11 (December 12, 2011) -------------------------- This version of beets focuses on transitioning the autotagger to the new version of the MusicBrainz database (called NGS). This transition brings with it a number of long-overdue improvements: most notably, predictable behavior when tagging multi-disc albums and integration with the new Acoustid_ acoustic fingerprinting technology. The importer can also now tag *incomplete* albums when you're missing a few tracks from a given release. Two other new plugins are also included with this release: one for assigning genres and another for ReplayGain analysis. - Beets now communicates with MusicBrainz via the new `Next Generation Schema`_ (NGS) service via python-musicbrainzngs_. The bindings are included with this version of beets, but a future version will make them an external dependency. - The importer now detects **multi-disc albums** and tags them together. Using a heuristic based on the names of directories, certain structures are classified as multi-disc albums: for example, if a directory contains subdirectories labeled "disc 1" and "disc 2", these subdirectories will be coalesced into a single album for tagging. - The new :doc:`/plugins/chroma` uses the Acoustid_ **open-source acoustic fingerprinting** service. This replaces the old ``lastid`` plugin, which used Last.fm fingerprinting and is now deprecated. Fingerprinting with this library should be faster and more reliable. - The importer can now perform **partial matches**. This means that, if you're missing a few tracks from an album, beets can still tag the remaining tracks as a single album. (Thanks to `Simon Chopin`_.) - The new :doc:`/plugins/lastgenre` automatically **assigns genres to imported albums** and items based on Last.fm tags and an internal whitelist. (Thanks to KraYmer_.) - The :doc:`/plugins/replaygain`, written by `Peter Brunner`_, has been merged into the core beets distribution. Use it to analyze audio and **adjust playback levels** in ReplayGain-aware music players. - Albums are now tagged with their *original* release date rather than the date of any reissue, remaster, "special edition", or the like. - The config file and library databases are now given better names and locations on Windows. Namely, both files now reside in ``%APPDATA%``; the config file is named ``beetsconfig.ini`` and the database is called ``beetslibrary.blb`` (neither has a leading dot as on Unix). For backwards compatibility, beets will check the old locations first. - When entering an ID manually during tagging, beets now searches for anything that looks like an MBID in the entered string. This means that full MusicBrainz URLs now work as IDs at the prompt. (Thanks to derwin.) - The importer now ignores certain "clutter" files like ``.AppleDouble`` directories and ``._*`` files. The list of ignored patterns is configurable via the ``ignore`` setting; see :doc:`/reference/config`. - The database now keeps track of files' modification times so that, during an ``update``, unmodified files can be skipped. (Thanks to Jos van der Til.) - The album art fetcher now uses albumart.org_ as a fallback when the Amazon art downloader fails. - A new ``timeout`` config value avoids database locking errors on slow systems. - Fix a crash after using the "as Tracks" option during import. - Fix a Unicode error when tagging items with missing titles. - Fix a crash when the state file (``~/.beetsstate``) became emptied or corrupted. .. _acoustid: https://acoustid.org/ .. _albumart.org: https://web.archive.org/web/20191217041318/http://www.albumart.org/ .. _kraymer: https://github.com/KraYmer .. _next generation schema: https://musicbrainz.org/doc/MusicBrainz_API .. _peter brunner: https://github.com/Lugoues .. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs .. _simon chopin: https://github.com/laarmen 1.0b10 (September 22, 2011) --------------------------- This version of beets focuses on making it easier to manage your metadata *after* you've imported it. A bumper crop of new commands has been added: a manual tag editor (``modify``), a tool to pick up out-of-band deletions and modifications (``update``), and functionality for moving and copying files around (``move``). Furthermore, the concept of "re-importing" is new: you can choose to re-run beets' advanced autotagger on any files you already have in your library if you change your mind after you finish the initial import. As a couple of added bonuses, imports can now automatically skip previously-imported directories (with the ``-i`` flag) and there's an :doc:`experimental Web interface </plugins/web>` to beets in a new standard plugin. - A new ``beet modify`` command enables **manual, command-line-based modification** of music metadata. Pass it a query along with ``field=value`` pairs that specify the changes you want to make. - A new ``beet update`` command updates the database to reflect **changes in the on-disk metadata**. You can now use an external program to edit tags on files, remove files and directories, etc., and then run ``beet update`` to make sure your beets library is in sync. This will also rename files to reflect their new metadata. - A new ``beet move`` command can **copy or move files** into your library directory or to another specified directory. - When importing files that are already in the library database, the items are no longer duplicated---instead, the library is updated to reflect the new metadata. This way, the import command can be transparently used as a **re-import**. - Relatedly, the ``-L`` flag to the "import" command makes it take a query as its argument instead of a list of directories. The matched albums (or items, depending on the ``-s`` flag) are then re-imported. - A new flag ``-i`` to the import command runs **incremental imports**, keeping track of and skipping previously-imported directories. This has the effect of making repeated import commands pick up only newly-added directories. The ``import_incremental`` config option makes this the default. - When pruning directories, "clutter" files such as ``.DS_Store`` and ``Thumbs.db`` are ignored (and removed with otherwise-empty directories). - The :doc:`/plugins/web` encapsulates a simple **Web-based GUI for beets**. The current iteration can browse the library and play music in browsers that support HTML5 Audio. - When moving items that are part of an album, the album art implicitly moves too. - Files are no longer silently overwritten when moving and copying files. - Handle exceptions thrown when running Mutagen. - Fix a missing ``__future__`` import in ``embed art`` on Python 2.5. - Fix ID3 and MPEG-4 tag names for the album-artist field. - Fix Unicode encoding of album artist, album type, and label. - Fix crash when "copying" an art file that's already in place. 1.0b9 (July 9, 2011) -------------------- This release focuses on a large number of small fixes and improvements that turn beets into a well-oiled, music-devouring machine. See the full release notes, below, for a plethora of new features. - **Queries can now contain whitespace.** Spaces passed as shell arguments are now preserved, so you can use your shell's escaping syntax (quotes or backslashes, for instance) to include spaces in queries. For example, ``beet ls "the knife"`` or ``beet ls theknife``. Read more in :doc:`/reference/query`. - Queries can **match items from the library by directory**. A ``path:`` prefix is optional; any query containing a path separator (/ on POSIX systems) is assumed to be a path query. Running ``beet ls path/to/music`` will show all the music in your library under the specified directory. The :doc:`/reference/query` reference again has more details. - **Local album art** is now automatically discovered and copied from the imported directories when available. - When choosing the "as-is" import album (or doing a non-autotagged import), **every album either has an "album artist" set or is marked as a compilation (Various Artists)**. The choice is made based on the homogeneity of the tracks' artists. This prevents compilations that are imported as-is from being scattered across many directories after they are imported. - The release **label** for albums and tracks is now fetched from !MusicBrainz, written to files, and stored in the database. - The "list" command now accepts a ``-p`` switch that causes it to **show paths** instead of titles. This makes the output of ``beet ls -p`` suitable for piping into another command such as xargs_. - Release year and label are now shown in the candidate selection list to help disambiguate different releases of the same album. - Prompts in the importer interface are now colorized for easy reading. The default option is always highlighted. - The importer now provides the option to specify a MusicBrainz ID manually if the built-in searching isn't working for a particular album or track. - ``$bitrate`` in path formats is now formatted as a human-readable kbps value instead of as a raw integer. - The import logger has been improved for "always-on" use. First, it is now possible to specify a log file in .beetsconfig. Also, logs are now appended rather than overwritten and contain timestamps. - Album art fetching and plugin events are each now run in separate pipeline stages during imports. This should bring additional performance when using album art plugins like embedart or beets-lyrics. - Accents and other Unicode decorators on characters are now treated more fairly by the autotagger. For example, if you're missing the acute accent on the "e" in "café", that change won't be penalized. This introduces a new dependency on the unidecode_ Python module. - When tagging a track with no title set, the track's filename is now shown (instead of nothing at all). - The bitrate of lossless files is now calculated from their file size (rather than being fixed at 0 or reflecting the uncompressed audio bitrate). - Fixed a problem where duplicate albums or items imported at the same time would fail to be detected. - BPD now uses a persistent "virtual filesystem" in order to fake a directory structure. This means that your path format settings are respected in BPD's browsing hierarchy. This may come at a performance cost, however. The virtual filesystem used by BPD is available for reuse by plugins (e.g., the FUSE plugin). - Singleton imports (``beet import -s``) can now take individual files as arguments as well as directories. - Fix Unicode queries given on the command line. - Fix crasher in quiet singleton imports (``import -qs``). - Fix crash when autotagging files with no metadata. - Fix a rare deadlock when finishing the import pipeline. - Fix an issue that was causing mpdupdate to run twice for every album. - Fix a bug that caused release dates/years not to be fetched. - Fix a crasher when setting MBIDs on MP3s file metadata. - Fix a "broken pipe" error when piping beets' standard output. - A better error message is given when the database file is unopenable. - Suppress errors due to timeouts and bad responses from MusicBrainz. - Fix a crash on album queries with item-only field names. .. _unidecode: https://pypi.org/project/Unidecode/0.04.1/ .. _xargs: https://en.wikipedia.org/wiki/Xargs 1.0b8 (April 28, 2011) ---------------------- This release of beets brings two significant new features. First, beets now has first-class support for "singleton" tracks. Previously, it was only really meant to manage whole albums, but many of us have lots of non-album tracks to keep track of alongside our collections of albums. So now beets makes it easy to tag, catalog, and manipulate your individual tracks. Second, beets can now (optionally) embed album art directly into file metadata rather than only storing it in a "file on the side." Check out the :doc:`/plugins/embedart` for that functionality. - Better support for **singleton (non-album) tracks**. Whereas beets previously only really supported full albums, now it can also keep track of individual, off-album songs. The "singleton" path format can be used to customize where these tracks are stored. To import singleton tracks, provide the -s switch to the import command or, while doing a normal full-album import, choose the "as Tracks" (T) option to add singletons to your library. To list only singleton or only album tracks, use the new ``singleton:`` query term: the query ``singleton:true`` matches only singleton tracks; ``singleton:false`` matches only album tracks. The ``lastid`` plugin has been extended to support matching individual items as well. - The importer/autotagger system has been heavily refactored in this release. If anything breaks as a result, please get in touch or just file a bug. - Support for **album art embedded in files**. A new :doc:`/plugins/embedart` implements this functionality. Enable the plugin to automatically embed downloaded album art into your music files' metadata. The plugin also provides the "embedart" and "extractart" commands for moving image files in and out of metadata. See the wiki for more details. (Thanks, daenney!) - The "distance" number, which quantifies how different an album's current and proposed metadata are, is now displayed as "similarity" instead. This should be less noisy and confusing; you'll now see 99.5% instead of 0.00489323. - A new "timid mode" in the importer asks the user every time, even when it makes a match with very high confidence. The ``-t`` flag on the command line and the ``import_timid`` config option control this mode. (Thanks to mdecker on GitHub!) - The multithreaded importer should now abort (either by selecting aBort or by typing ^C) much more quickly. Previously, it would try to get a lot of work done before quitting; now it gives up as soon as it can. - Added a new plugin event, ``album_imported``, which is called every time an album is added to the library. (Thanks, Lugoues!) - A new plugin method, ``register_listener``, is an imperative alternative to the ``@listen`` decorator (Thanks again, Lugoues!) - In path formats, ``$albumartist`` now falls back to ``$artist`` (as well as the other way around). - The importer now prints "(unknown album)" when no tags are present. - When autotagging, "and" is considered equal to "&". - Fix some crashes when deleting files that don't exist. - Fix adding individual tracks in BPD. - Fix crash when ``~/.beetsconfig`` does not exist. 1.0b7 (April 5, 2011) --------------------- Beta 7's focus is on better support for "various artists" releases. These albums can be treated differently via the new ``[paths]`` config section and the autotagger is better at handling them. It also includes a number of oft-requested improvements to the ``beet`` command-line tool, including several new configuration options and the ability to clean up empty directory subtrees. - **"Various artists" releases** are handled much more gracefully. The autotagger now sets the ``comp`` flag on albums whenever the album is identified as a "various artists" release by !MusicBrainz. Also, there is now a distinction between the "album artist" and the "track artist", the latter of which is never "Various Artists" or other such bogus stand-in. *(Thanks to Jonathan for the bulk of the implementation work on this feature!)* - The directory hierarchy can now be **customized based on release type**. In particular, the ``path_format`` setting in .beetsconfig has been replaced with a new ``[paths]`` section, which allows you to specify different path formats for normal and "compilation" (various artists) releases as well as for each album type (see below). The default path formats have been changed to use ``$albumartist`` instead of ``$artist``. - A **new** ``albumtype`` **field** reflects the release type `as specified by MusicBrainz`_. - When deleting files, beets now appropriately "prunes" the directory tree---empty directories are automatically cleaned up. *(Thanks to wlof on GitHub for this!)* - The tagger's output now always shows the album directory that is currently being tagged. This should help in situations where files' current tags are missing or useless. - The logging option (``-l``) to the ``import`` command now logs duplicate albums. - A new ``import_resume`` configuration option can be used to disable the importer's resuming feature or force it to resume without asking. This option may be either ``yes``, ``no``, or ``ask``, with the obvious meanings. The ``-p`` and ``-P`` command-line flags override this setting and correspond to the "yes" and "no" settings. - Resuming is automatically disabled when the importer is in quiet (``-q``) mode. Progress is still saved, however, and the ``-p`` flag (above) can be used to force resuming. - The ``BEETSCONFIG`` environment variable can now be used to specify the location of the config file that is at ~/.beetsconfig by default. - A new ``import_quiet_fallback`` config option specifies what should happen in quiet mode when there is no strong recommendation. The options are ``skip`` (the default) and "asis". - When importing with the "delete" option and importing files that are already at their destination, files could be deleted (leaving zero copies afterward). This is fixed. - The ``version`` command now lists all the loaded plugins. - A new plugin, called ``info``, just prints out audio file metadata. - Fix a bug where some files would be erroneously interpreted as MPEG-4 audio. - Fix permission bits applied to album art files. - Fix malformed !MusicBrainz queries caused by null characters. - Fix a bug with old versions of the Monkey's Audio format. - Fix a crash on broken symbolic links. - Retry in more cases when !MusicBrainz servers are slow/overloaded. - The old "albumify" plugin for upgrading databases was removed. .. _as specified by musicbrainz: https://wiki.musicbrainz.org/ReleaseType 1.0b6 (January 20, 2011) ------------------------ This version consists primarily of bug fixes and other small improvements. It's in preparation for a more feature-ful release in beta 7. The most important issue involves correct ordering of autotagged albums. - **Quiet import:** a new "-q" command line switch for the import command suppresses all prompts for input; it pessimistically skips all albums that the importer is not completely confident about. - Added support for the **WavPack** and **Musepack** formats. Unfortunately, due to a limitation in the Mutagen library (used by beets for metadata manipulation), Musepack SV8 is not yet supported. Here's the `upstream bug`_ in question. - BPD now uses a pure-Python socket library and no longer requires eventlet/greenlet (the latter of which is a C extension). For the curious, the socket library in question is called Bluelet_. - Non-autotagged imports are now resumable (just like autotagged imports). - Fix a terrible and long-standing bug where track orderings were never applied. This manifested when the tagger appeared to be applying a reasonable ordering to the tracks but, later, the database reflects a completely wrong association of track names to files. The order applied was always just alphabetical by filename, which is frequently but not always what you want. - We now use Windows' "long filename" support. This API is fairly tricky, though, so some instability may still be present---please file a bug if you run into pathname weirdness on Windows. Also, filenames on Windows now never end in spaces. - Fix crash in lastid when the artist name is not available. - Fixed a spurious crash when ``LANG`` or a related environment variable is set to an invalid value (such as ``'UTF-8'`` on some installations of Mac OS X). - Fixed an error when trying to copy a file that is already at its destination. - When copying read-only files, the importer now tries to make the copy writable. (Previously, this would just crash the import.) - Fixed an ``UnboundLocalError`` when no matches are found during autotag. - Fixed a Unicode encoding error when entering special characters into the "manual search" prompt. - Added ``beet version`` command that just shows the current release version. .. _bluelet: https://github.com/sampsyo/bluelet .. _upstream bug: https://github.com/quodlibet/mutagen/issues/7 1.0b5 (September 28, 2010) -------------------------- This version of beets focuses on increasing the accuracy of the autotagger. The main addition is an included plugin that uses acoustic fingerprinting to match based on the audio content (rather than existing metadata). Additional heuristics were also added to the metadata-based tagger as well that should make it more reliable. This release also greatly expands the capabilities of beets' :doc:`plugin API </plugins/index>`. A host of other little features and fixes are also rolled into this release. - The ``lastid`` plugin adds Last.fm **acoustic fingerprinting support** to the autotagger. Similar to the PUIDs used by !MusicBrainz Picard, this system allows beets to recognize files that don't have any metadata at all. You'll need to install some dependencies for this plugin to work. - To support the above, there's also a new system for **extending the autotagger via plugins**. Plugins can currently add components to the track and album distance functions as well as augment the MusicBrainz search. The new API is documented at :doc:`/plugins/index`. - **String comparisons** in the autotagger have been augmented to act more intuitively. Previously, if your album had the title "Something (EP)" and it was officially called "Something", then beets would think this was a fairly significant change. It now checks for and appropriately reweights certain parts of each string. As another example, the title "The Great Album" is considered equal to "Great Album, The". - New **event system for plugins** (thanks, Jeff!). Plugins can now get callbacks from beets when certain events occur in the core. Again, the API is documented in :doc:`/plugins/index`. - The BPD plugin is now disabled by default. This greatly simplifies installation of the beets core, which is now 100% pure Python. To use BPD, though, you'll need to set ``plugins: bpd`` in your .beetsconfig. - The ``import`` command can now remove original files when it copies items into your library. (This might be useful if you're low on disk space.) Set the ``import_delete`` option in your .beetsconfig to ``yes``. - Importing without autotagging (``beet import -A``) now prints out album names as it imports them to indicate progress. - The new :doc:`/plugins/mpdupdate` will automatically update your MPD server's index whenever your beets library changes. - Efficiency tweak should reduce the number of !MusicBrainz queries per autotagged album. - A new ``-v`` command line switch enables debugging output. - Fixed bug that completely broke non-autotagged imports (``import -A``). - Fixed bug that logged the wrong paths when using ``import -l``. - Fixed autotagging for the creatively-named band `!!!`_. - Fixed normalization of relative paths. - Fixed escaping of ``/`` characters in paths on Windows. .. _!!!: https://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7 1.0b4 (August 9, 2010) ---------------------- This thrilling new release of beets focuses on making the tagger more usable in a variety of ways. First and foremost, it should now be much faster: the tagger now uses a multithreaded algorithm by default (although, because the new tagger is experimental, a single-threaded version is still available via a config option). Second, the tagger output now uses a little bit of ANSI terminal coloring to make changes stand out. This way, it should be faster to decide what to do with a proposed match: the more red you see, the worse the match is. Finally, the tagger can be safely interrupted (paused) and restarted later at the same point. Just enter ``b`` for aBort at any prompt to stop the tagging process and save its progress. (The progress-saving also works in the unthinkable event that beets crashes while tagging.) Among the under-the-hood changes in 1.0b4 is a major change to the way beets handles paths (filenames). This should make the whole system more tolerant to special characters in filenames, but it may break things (especially databases created with older versions of beets). As always, let me know if you run into weird problems with this release. Finally, this release's ``setup.py`` should install a ``beet.exe`` startup stub for Windows users. This should make running beets much easier: just type ``beet`` if you have your ``PATH`` environment variable set up correctly. The :doc:`/guides/main` guide has some tips on installing beets on Windows. Here's the detailed list of changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - **Parallel tagger.** The autotagger has been reimplemented to use multiple threads. This means that it can concurrently read files from disk, talk to the user, communicate with MusicBrainz, and write data back to disk. Not only does this make the tagger much faster because independent work may be performed in parallel, but it makes the tagging process much more pleasant for large imports. The user can let albums queue up in the background while making a decision rather than waiting for beets between each question it asks. The parallel tagger is on by default but a sequential (single- threaded) version is still available by setting the ``threaded`` config value to ``no`` (because the parallel version is still quite experimental). - **Colorized tagger output.** The autotagger interface now makes it a little easier to see what's going on at a glance by highlighting changes with terminal colors. This feature is on by default, but you can turn it off by setting ``color`` to ``no`` in your ``.beetsconfig`` (if, for example, your terminal doesn't understand colors and garbles the output). - **Pause and resume imports.** The ``import`` command now keeps track of its progress, so if you're interrupted (beets crashes, you abort the process, an alien devours your motherboard, etc.), beets will try to resume from the point where you left off. The next time you run ``import`` on the same directory, it will ask if you want to resume. It accomplishes this by "fast-forwarding" through the albums in the directory until it encounters the last one it saw. (This means it might fail if that album can't be found.) Also, you can now abort the tagging process by entering ``b`` (for aBort) at any of the prompts. - Overhauled methods for handling filesystem paths to allow filenames that have badly encoded special characters. These changes are pretty fragile, so please report any bugs involving ``UnicodeError`` or SQLite ``ProgrammingError`` messages in this version. - The destination paths (the library directory structure) now respect album-level metadata. This means that if you have an album in which two tracks have different album-level attributes (like year, for instance), they will still wind up in the same directory together. (There's currently not a very smart method for picking the "correct" album-level metadata, but we'll fix that later.) - Fixed a bug where the CLI would fail completely if the ``LANG`` environment variable was not set. - Fixed removal of albums (``beet remove -a``): previously, the album record would stay around although the items were deleted. - The setup script now makes a ``beet.exe`` startup stub on Windows; Windows users can now just type ``beet`` at the prompt to run beets. - Fixed an occasional bug where Mutagen would complain that a tag was already present. - Fixed a bug with reading invalid integers from ID3 tags. - The tagger should now be a little more reluctant to reorder tracks that already have indices. 1.0b3 (July 22, 2010) --------------------- This release features two major additions to the autotagger's functionality: album art fetching and MusicBrainz ID tags. It also contains some important under-the-hood improvements: a new plugin architecture is introduced and the database schema is extended with explicit support for albums. This release has one major backwards-incompatibility. Because of the new way beets handles albums in the library, databases created with an old version of beets might have trouble with operations that deal with albums (like the ``-a`` switch to ``beet list`` and ``beet remove``, as well as the file browser for BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin (see the fourth bullet point below). - **Album art.** The tagger now, by default, downloads album art from Amazon that is referenced in the MusicBrainz database. It places the album art alongside the audio files in a file called (for example) ``cover.jpg``. The ``import_art`` config option controls this behavior, as do the ``-r`` and ``-R`` options to the import command. You can set the name (minus extension) of the album art file with the ``art_filename`` config option. (See :doc:`/reference/config` for more information about how to configure the album art downloader.) - **Support for MusicBrainz ID tags.** The autotagger now keeps track of the MusicBrainz track, album, and artist IDs it matched for each file. It also looks for album IDs in new files it's importing and uses those to look up data in MusicBrainz. Furthermore, track IDs are used as a component of the tagger's distance metric now. (This obviously lays the groundwork for a utility that can update tags if the MB database changes, but that's `for the future`_.) Tangentially, this change required the database code to support a lightweight form of migrations so that new columns could be added to old databases--this is a delicate feature, so it would be very wise to make a backup of your database before upgrading to this version. - **Plugin architecture.** Add-on modules can now add new commands to the beets command-line interface. The ``bpd`` and ``dadd`` commands were removed from the beets core and turned into plugins; BPD is loaded by default. To load the non-default plugins, use the config options ``plugins`` (a space-separated list of plugin names) and ``pluginpath`` (a colon-separated list of directories to search beyond ``sys.path``). Plugins are just Python modules under the ``beetsplug`` namespace package containing subclasses of |BeetsPlugin|. See `the beetsplug directory`_ for examples or :doc:`/plugins/index` for instructions. - As a consequence of adding album art, the database was significantly refactored to keep track of some information at an album (rather than item) granularity. Databases created with earlier versions of beets should work fine, but they won't have any "albums" in them--they'll just be a bag of items. This means that commands like ``beet ls -a`` and ``beet rm -a`` won't match anything. To "upgrade" your database, you can use the included ``albumify`` plugin. Running ``beets albumify`` with the plugin activated (set ``plugins=albumify`` in your config file) will group all your items into albums, making beets behave more or less as it did before. - Fixed some bugs with encoding paths on Windows. Also, ``:`` is now replaced with ``-`` in path names (instead of ``_``) for readability. - ``MediaFile`` now has a ``format`` attribute, so you can use ``$format`` in your library path format strings like ``$artist - $album ($format)`` to get directories with names like ``Paul Simon - Graceland (FLAC)``. .. _for the future: https://github.com/google-code-export/beets/issues/69 .. _the beetsplug directory: https://github.com/beetbox/beets/tree/master/beetsplug Beets also now has its first third-party plugin: beetfs_, by Martin Eve! It exposes your music in a FUSE filesystem using a custom directory structure. Even cooler: it lets you keep your files intact on-disk while correcting their tags when accessed through FUSE. Check it out! .. _beetfs: https://github.com/jbaiter/beetfs 1.0b2 (July 7, 2010) -------------------- This release focuses on high-priority fixes and conspicuously missing features. Highlights include support for two new audio formats (Monkey's Audio and Ogg Vorbis) and an option to log untaggable albums during import. - **Support for Ogg Vorbis and Monkey's Audio** files and their tags. (This support should be considered preliminary: I haven't tested it heavily because I don't use either of these formats regularly.) - An option to the ``beet import`` command for **logging albums that are untaggable** (i.e., are skipped or taken "as-is"). Use ``beet import -l LOGFILE PATHS``. The log format is very simple: it's just a status (either "skip" or "asis") followed by the path to the album in question. The idea is that you can tag a large collection and automatically keep track of the albums that weren't found in MusicBrainz so you can come back and look at them later. - Fixed a ``UnicodeEncodeError`` on terminals that don't (or don't claim to) support UTF-8. - Importing without autotagging (``beet import -A``) is now faster and doesn't print out a bunch of whitespace. It also lets you specify single files on the command line (rather than just directories). - Fixed importer crash when attempting to read a corrupt file. - Reorganized code for CLI in preparation for adding pluggable subcommands. Also removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled solution`_. .. _a hand-rolled solution: https://gist.github.com/sampsyo/462717 1.0b1 (June 17, 2010) --------------------- Initial release. ================================================ FILE: docs/code_of_conduct.rst ================================================ .. code_of_conduct: .. include:: ../CODE_OF_CONDUCT.rst ================================================ FILE: docs/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import sys from pathlib import Path # Add custom extensions directory to path sys.path.insert(0, str(Path(__file__).parent / "extensions")) project = "beets" AUTHOR = "Adrian Sampson" copyright = "2016, Adrian Sampson" master_doc = "index" language = "en" version = "2.7" release = "2.7.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.extlinks", "sphinx.ext.viewcode", "sphinx_design", "sphinx_copybutton", "conf", "sphinx_toolbox.more_autodoc.autotypeddict", "sphinx_toolbox.more_autodoc.autonamedtuple", ] autosummary_generate = True autosummary_context = { "related_typeddicts": { "MusicBrainzAPI": [ "beetsplug._utils.musicbrainz.LookupKwargs", "beetsplug._utils.musicbrainz.SearchKwargs", "beetsplug._utils.musicbrainz.BrowseKwargs", "beetsplug._utils.musicbrainz.BrowseRecordingsKwargs", "beetsplug._utils.musicbrainz.BrowseReleaseGroupsKwargs", ], } } autodoc_member_order = "bysource" exclude_patterns = ["_build"] templates_path = ["_templates"] source_suffix = {".rst": "restructuredtext", ".md": "markdown"} pygments_style = "sphinx" # External links to the bug tracker and other sites. extlinks = { "bug": ("https://github.com/beetbox/beets/issues/%s", "#%s"), "user": ("https://github.com/%s", "%s"), "pypi": ("https://pypi.org/project/%s/", "%s"), "stdlib": ("https://docs.python.org/3/library/%s.html", "%s"), } linkcheck_ignore = [ r"https://github.com/beetbox/beets/issues/", r"https://github.com/[^/]+$", # ignore user pages r".*localhost.*", r"https?://127\.0\.0\.1", r"https://www.musixmatch.com/", # blocks requests r"https://genius.com/", # blocks requests r"https://sourceforge\.net/", # blocks requests r"https://[^/]*fanart\.tv/", # blocks requests r"https://[^/]*fandom\.com/", # blocks requests r"https://imgur\.com/", # not accessible from the UK r"https://www.discogs.com/settings/developers", # requires login ] # Options for HTML output htmlhelp_basename = "beetsdoc" # Options for LaTeX output latex_documents = [ ("index", "beets.tex", "beets Documentation", AUTHOR, "manual"), ] # Options for manual page output man_pages = [ ( "reference/cli", "beet", "music tagger and library organizer", [AUTHOR], 1, ), ( "reference/config", "beetsconfig", "beets configuration file", [AUTHOR], 5, ), ] # Global substitutions that can be used anywhere in the documentation. rst_epilog = r""" .. |Album| replace:: :class:`~beets.library.models.Album` .. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo` .. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin` .. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession` .. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask` .. |Item| replace:: :class:`~beets.library.models.Item` .. |Library| replace:: :class:`~beets.library.library.Library` .. |Model| replace:: :class:`~beets.dbcore.db.Model` .. |TrackInfo| replace:: :class:`beets.autotag.hooks.TrackInfo` .. |semicolon_space| replace:: :literal:`; \ ` """ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "pydata_sphinx_theme" html_theme_options = { "collapse_navigation": False, "logo": {"text": "beets"}, "show_nav_level": 2, # How many levels in left sidebar to show automatically "navigation_depth": 4, # How many levels of navigation to expand } html_title = "beets" html_logo = "_static/beets_logo_nobg.png" html_static_path = ["_static"] html_css_files = ["beets.css"] def skip_member(app, what, name, obj, skip, options): if name.startswith("_"): return True return skip def setup(app): app.connect("autodoc-skip-member", skip_member) ================================================ FILE: docs/contributing.rst ================================================ .. contributing: .. include:: ../CONTRIBUTING.rst ================================================ FILE: docs/dev/cli.rst ================================================ Providing a CLI =============== The ``beets.ui`` module houses interactions with the user via a terminal, the :doc:`/reference/cli`. The main function is called when the user types beet on the command line. The CLI functionality is organized into commands, some of which are built-in and some of which are provided by plugins. The built-in commands are all implemented in the ``beets.ui.commands`` submodule. ================================================ FILE: docs/dev/importer.rst ================================================ Music Importer ============== The importer component is responsible for the user-centric workflow that adds music to a library. This is one of the first aspects that a user experiences when using beets: it finds music in the filesystem, groups it into albums, finds corresponding metadata in MusicBrainz, asks the user for intervention, applies changes, and moves/copies files. A description of its user interface is given in :doc:`/guides/tagger`. The workflow is implemented in the ``beets.importer`` module and is distinct from the core logic for matching MusicBrainz metadata (in the ``beets.autotag`` module). The workflow is also decoupled from the command-line interface with the hope that, eventually, other (graphical) interfaces can be bolted onto the same importer implementation. The importer is multithreaded and follows the pipeline pattern. Each pipeline stage is a Python coroutine. The ``beets.util.pipeline`` module houses a generic, reusable implementation of a multithreaded pipeline. ================================================ FILE: docs/dev/index.rst ================================================ For Developers ============== This section contains information for developers. Read on if you're interested in hacking beets itself or creating plugins for it. See also the documentation for the MediaFile_ and Confuse_ libraries. These are maintained by the beets team and used to read and write metadata tags and manage configuration files, respectively. .. _confuse: https://confuse.readthedocs.io/en/latest/ .. _mediafile: https://mediafile.readthedocs.io/en/latest/ .. toctree:: :maxdepth: 3 :titlesonly: plugins/index library paths importer cli ../api/index ================================================ FILE: docs/dev/library.rst ================================================ Library Database API ==================== .. currentmodule:: beets.library This page describes the internal API of beets' core database features. It doesn't exhaustively document the API, but is aimed at giving an overview of the architecture to orient anyone who wants to dive into the code. The |Library| object is the central repository for data in beets. It represents a database containing songs, which are |Item| instances, and groups of items, which are |Album| instances. The Library Class ----------------- The |Library| is typically instantiated as a singleton. A single invocation of beets usually has only one |Library|. It's powered by :class:`dbcore.Database` under the hood, which handles the SQLite_ abstraction, something like a very minimal ORM_. The library is also responsible for handling queries to retrieve stored objects. Overview ~~~~~~~~ You can add new items or albums to the library via the :py:meth:`Library.add` and :py:meth:`Library.add_album` methods. You may also query the library for items and albums using the :py:meth:`Library.items`, :py:meth:`Library.albums`, :py:meth:`Library.get_item` and :py:meth:`Library.get_album` methods. Any modifications to the library must go through a :class:`Transaction` object, which you can get using the :py:meth:`Library.transaction` context manager. .. _orm: https://en.wikipedia.org/wiki/Object-relational_mapping .. _sqlite: https://sqlite.org/index.html Model Classes ------------- The two model entities in beets libraries, |Item| and |Album|, share a base class, :class:`LibModel`, that provides common functionality. That class itself specialises :class:`beets.dbcore.Model` which provides an ORM-like abstraction. To get or change the metadata of a model (an item or album), either access its attributes (e.g., ``print(album.year)`` or ``album.year = 2012``) or use the ``dict``-like interface (e.g. ``item['artist']``). Model base ~~~~~~~~~~ Models use dirty-flags to track when the object's metadata goes out of sync with the database. The dirty dictionary maps field names to booleans indicating whether the field has been written since the object was last synchronized (via load or store) with the database. This logic is implemented in the model base class :class:`LibModel` and is inherited by both |Item| and |Album|. We provide CRUD-like methods for interacting with the database: - :py:meth:`LibModel.store` - :py:meth:`LibModel.load` - :py:meth:`LibModel.remove` - :py:meth:`LibModel.add` The base class :class:`beets.dbcore.Model` has a ``dict``-like interface, so normal the normal mapping API is supported: - :py:meth:`LibModel.keys` - :py:meth:`LibModel.update` - :py:meth:`LibModel.items` - :py:meth:`LibModel.get` Item ~~~~ Each |Item| object represents a song or track. (We use the more generic term item because, one day, beets might support non-music media.) An item can either be purely abstract, in which case it's just a bag of metadata fields, or it can have an associated file (indicated by ``item.path``). In terms of the underlying SQLite database, items are backed by a single table called items with one column per metadata fields. The metadata fields currently in use are listed in ``library.py`` in ``Item._fields``. To read and write a file's tags, we use the MediaFile_ library. To make changes to either the database or the tags on a file, you update an item's fields (e.g., ``item.title = "Let It Be"``) and then call ``item.write()``. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ Items also track their modification times (mtimes) to help detect when they become out of sync with on-disk metadata, mainly to speed up the :ref:`update-cmd` (which needs to check whether the database is in sync with the filesystem). This feature turns out to be sort of complicated. For any |Item|, there are two mtimes: the on-disk mtime (maintained by the OS) and the database mtime (maintained by beets). Correspondingly, there is on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB metadata are in sync; this lets beets do a quick mtime check and avoid rereading files in some circumstances. Specifically, beets attempts to maintain the following invariant: If the on-disk metadata differs from the DB metadata, then the on-disk mtime must be greater than the DB mtime. As a result, it is always valid for the DB mtime to be zero (assuming that real disk mtimes are always positive). However, whenever possible, beets tries to set ``db_mtime = disk_mtime`` at points where it knows the metadata is synchronized. When it is possible that the metadata is out of sync, beets can then just set ``db_mtime = 0`` to return to a consistent state. This leads to the following implementation policy: - On every write of disk metadata (``Item.write()``), the DB mtime is updated to match the post-write disk mtime. - Same for metadata reads (``Item.read()``). - On every modification to DB metadata (``item.field = ...``), the DB mtime is reset to zero. Album ~~~~~ An |Album| is a collection of Items in the database. Every item in the database has either zero or one associated albums (accessible via ``item.album_id``). An item that has no associated album is called a singleton. Changing fields on an album (e.g. ``album.year = 2012``) updates the album itself and also changes the same field in all associated items. An |Album| object keeps track of album-level metadata, which is (mostly) a subset of the track-level metadata. The album-level metadata fields are listed in ``Album._fields``. For those fields that are both item-level and album-level (e.g., ``year`` or ``albumartist``), every item in an album should share the same value. Albums use an SQLite table called ``albums``, in which each column is an album metadata field. .. note:: The :py:meth:`Album.items` method is not inherited from :py:meth:`LibModel.items` for historical reasons. Transactions ~~~~~~~~~~~~ The |Library| class provides the basic methods necessary to access and manipulate its contents. To perform more complicated operations atomically, or to interact directly with the underlying SQLite database, you must use a *transaction* (see this `blog post`_ for motivation). For example .. code-block:: python lib = Library() with lib.transaction() as tx: items = lib.items(query) lib.add_album(list(items)) .. currentmodule:: beets.dbcore.db The :class:`Transaction` class is a context manager that provides a transactional interface to the underlying SQLite database. It is responsible for managing the transaction's lifecycle, including beginning, committing, and rolling back the transaction if an error occurs. .. _blog post: https://beets.io/blog/sqlite-nightmare.html Migrations ~~~~~~~~~~ The database layer includes a first-class migration system for data changes that must happen alongside schema evolution. This keeps compatibility work explicit, testable, and isolated from normal query and model code. Each database subclass declares its migrations in ``_migrations`` as pairs of a migration class and the model classes it applies to. During startup, the database creates required tables and columns first, then executes configured migrations. Migration completion is tracked in a dedicated ``migrations`` table keyed by migration name and table name. This means each migration runs at most once per table, so large one-time data rewrites can be safely coordinated across restarts. The migration name is derived from the migration class name. Because that name is the persisted identity in the ``migrations`` table, renaming a released migration class changes its identity and can cause the migration to run again. Treat migration class names as stable once shipped. For example, ``MultiGenreFieldMigration`` becomes ``multi_genre_field``. After it runs for the ``items`` table, beets records a row equivalent to: .. code-block:: text name = "multi_genre_field", table_name = "items" Common use cases include: 1. Backfilling a newly introduced canonical field from older data. 2. Normalizing legacy free-form values into a structured representation. 3. Splitting mixed-content legacy fields into cleaned primary content plus auxiliary metadata stored as flexible attributes. To add a migration: 1. Create a :class:`beets.dbcore.db.Migration` subclass. 2. Implement the table-specific data rewrite logic in ``_migrate_data``. 3. Register the migration in the database subclass ``_migrations`` list for the target models. In practice, migrations should be idempotent and conservative: gate behavior on the current schema when needed, keep writes transactional, and batch large updates so startup remains predictable for real libraries. Queries ------- .. currentmodule:: beets.dbcore.query To access albums and items in a library, we use :doc:`/reference/query`. In beets, the :class:`Query` abstract base class represents a criterion that matches items or albums in the database. Every subclass of :class:`Query` must implement two methods, which implement two different ways of identifying matching items/albums. The ``clause()`` method should return an SQLite ``WHERE`` clause that matches appropriate albums/items. This allows for efficient batch queries. Correspondingly, the ``match(item)`` method should take an |Item| object and return a boolean, indicating whether or not a specific item matches the criterion. This alternate implementation allows clients to determine whether items that have already been fetched from the database match the query. There are many different types of queries. Just as an example, :class:`FieldQuery` determines whether a certain field matches a certain value (an equality query). :class:`AndQuery` (like its abstract superclass, :class:`CollectionQuery`) takes a set of other query objects and bundles them together, matching only albums/items that match all constituent queries. Beets has a human-writable plain-text query syntax that can be parsed into :class:`Query` objects. Calling ``AndQuery.from_strings`` parses a list of query parts into a query object that can then be used with |Library| objects. ================================================ FILE: docs/dev/paths.rst ================================================ Handling Paths ============== ``pathlib`` provides a clean, cross-platform API for working with filesystem paths. Use the ``.filepath`` property on ``Item`` and ``Album`` library objects to access paths as ``pathlib.Path`` objects. This produces a readable, native representation suitable for printing, logging, or further processing. Normalize paths using ``Path(...).expanduser().resolve()``, which expands ``~`` and resolves symlinks. Cross-platform differences—such as path separators, Unicode handling, and long-path support (Windows) are automatically managed by ``pathlib``. When storing paths in the database, however, convert them to bytes with ``bytestring_path()``. Paths in Beets are currently stored as bytes, although there are plans to eventually store ``pathlib.Path`` objects directly. To access media file paths in their stored form, use the ``.path`` property on ``Item`` and ``Album``. Legacy utilities ---------------- Historically, Beets used custom utilities to ensure consistent behavior across Linux, macOS, and Windows before ``pathlib`` became reliable: - ``syspath()``: worked around Windows Unicode and long-path limitations by converting to a system-safe string (adding the ``\\?\`` prefix where needed). - ``normpath()``: normalized slashes and removed ``./`` or ``..`` parts but did not expand ``~``. - ``bytestring_path()``: converted paths to bytes for database storage (still used for that purpose today). - ``displayable_path()``: converted byte paths to Unicode for display or logging. These functions remain safe to use in legacy code, but new code should rely solely on ``pathlib.Path``. Examples -------- Old style .. code-block:: python displayable_path(item.path) normpath("~/Music/../Artist") syspath(path) New style .. code-block:: python item.filepath Path("~/Music/../Artist").expanduser().resolve() Path(path) When storing paths in the database .. code-block:: python path_bytes = bytestring_path(Path("/some/path/to/file.mp3")) ================================================ FILE: docs/dev/plugins/autotagger.rst ================================================ Extending the Autotagger ======================== .. currentmodule:: beets.metadata_plugins Beets supports **metadata source plugins**, which allow it to fetch and match metadata from external services (such as Spotify, Discogs, or Deezer). This guide explains how to build your own metadata source plugin by extending either :py:class:`MetadataSourcePlugin` or :py:class:`SearchApiMetadataSourcePlugin`. These plugins integrate directly with the autotagger, providing candidate metadata during lookups. To implement one, you must subclass :py:class:`MetadataSourcePlugin` (or the search API helper base class) and implement its abstract methods. Overview -------- Creating a metadata source plugin is very similar to writing a standard plugin (see :ref:`basic-plugin-setup`). The main difference is that your plugin must: 1. Subclass :py:class:`MetadataSourcePlugin` or :py:class:`SearchApiMetadataSourcePlugin`. 2. Implement all required abstract methods. Here`s a minimal example: .. code-block:: python # beetsplug/myawesomeplugin.py from typing import Sequence from beets.autotag.hooks import Item from beets.metadata_plugins import MetadataSourcePlugin class MyAwesomePlugin(MetadataSourcePlugin): def candidates( self, items: Sequence[Item], artist: str, album: str, va_likely: bool, ): ... def item_candidates(self, item: Item, artist: str, title: str): ... def track_for_id(self, track_id: str): ... def album_for_id(self, album_id: str): ... For API-backed metadata sources, prefer :py:class:`SearchApiMetadataSourcePlugin` to reuse shared search orchestration: .. code-block:: python from beets.metadata_plugins import SearchApiMetadataSourcePlugin, SearchParams class MyApiPlugin(SearchApiMetadataSourcePlugin): def get_search_query_with_filters(self, query_type, items, artist, name, va_likely): query = f'album:"{name}"' if query_type == "album" else name if query_type == "track" or not va_likely: query += f' artist:"{artist}"' return query, {} def get_search_response(self, params: SearchParams): # Execute provider API request and return results with "id" fields. ... def track_for_id(self, track_id: str): ... def album_for_id(self, album_id: str): ... The shared base class centralizes query normalization, ``search_limit`` handling, candidate wiring, and consistent error logging for search requests. Provider-specific behavior is implemented in :py:meth:`~SearchApiMetadataSourcePlugin.get_search_query_with_filters` and :py:meth:`~SearchApiMetadataSourcePlugin.get_search_response`. Each metadata source plugin automatically gets a unique identifier. You can access this identifier using the :py:meth:`~MetadataSourcePlugin.data_source` class property to tell plugins apart. Metadata lookup --------------- When beets runs the autotagger, it queries **all enabled metadata source plugins** for potential matches: - For **albums**, it calls :py:meth:`~MetadataSourcePlugin.candidates`. - For **singletons**, it calls :py:meth:`~MetadataSourcePlugin.item_candidates`. The results are combined and scored. By default, candidate ranking is handled automatically by the beets core, but you can customize weighting by overriding: - :py:meth:`~MetadataSourcePlugin.album_distance` - :py:meth:`~MetadataSourcePlugin.track_distance` This is optional, if not overridden, both methods return a constant distance of `0.5`. ID-based lookups ---------------- Your plugin must also define: - :py:meth:`~MetadataSourcePlugin.album_for_id` — fetch album metadata by ID. - :py:meth:`~MetadataSourcePlugin.track_for_id` — fetch track metadata by ID. IDs are expected to be strings. If your source uses specific formats, consider contributing an extractor regex to the core module: :py:mod:`beets.util.id_extractors`. When beets matches by explicit IDs (for example via ``--search-id`` or existing ``mb_*id`` fields), it asks every enabled metadata source plugin for candidates using :py:meth:`~MetadataSourcePlugin.albums_for_ids` and :py:meth:`~MetadataSourcePlugin.tracks_for_ids`. Candidate identity is tracked by ``(data_source, id)``, so identical IDs from different providers remain separate options. If you need to query one specific provider, use the module helpers :py:func:`beets.metadata_plugins.album_for_id` and :py:func:`beets.metadata_plugins.track_for_id` and pass both the ID and the provider ``data_source`` name. Best practices -------------- Beets already ships with several metadata source plugins. Studying these implementations can help you follow conventions and avoid pitfalls. Good starting points include: - ``spotify`` - ``deezer`` - ``discogs`` Migration guidance ------------------ Older metadata plugins that extend |BeetsPlugin| should be migrated to :py:class:`MetadataSourcePlugin`. API-backed sources should generally migrate to :py:class:`SearchApiMetadataSourcePlugin` to avoid duplicating search orchestration. Legacy support will be removed in **beets v3.0.0**. .. seealso:: - :py:mod:`beets.autotag` - :py:mod:`beets.metadata_plugins` - :ref:`autotagger_extensions` - :ref:`using-the-auto-tagger` ================================================ FILE: docs/dev/plugins/commands.rst ================================================ .. _add_subcommands: Add Commands to the CLI ======================= Plugins can add new subcommands to the ``beet`` command-line interface. Define the plugin class' ``commands()`` method to return a list of ``Subcommand`` objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.) Here's an example plugin that adds a simple command: .. code-block:: python from beets.plugins import BeetsPlugin from beets.ui import Subcommand my_super_command = Subcommand("super", help="do something super") def say_hi(lib, opts, args): print("Hello everybody! I'm a plugin!") my_super_command.func = say_hi class SuperPlug(BeetsPlugin): def commands(self): return [my_super_command] To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser, help, aliases)``. The ``name`` parameter is the only required one and should just be the name of your command. ``parser`` can be an `OptionParser instance`_, but it defaults to an empty parser (you can extend it later). ``help`` is a description of your command, and ``aliases`` is a list of shorthand versions of your command name. .. _optionparser instance: https://docs.python.org/3/library/optparse.html You'll need to add a function to your command by saying ``mycommand.func = myfunction``. This function should take the following parameters: ``lib`` (a beets ``Library`` object) and ``opts`` and ``args`` (command-line options and arguments as returned by OptionParser.parse_args_). .. _optionparser.parse_args: https://docs.python.org/3/library/optparse.html#parsing-arguments The function should use any of the utility functions defined in ``beets.ui``. Try running ``pydoc beets.ui`` to see what's available. You can add command-line options to your new command using the ``parser`` member of the ``Subcommand`` class, which is a ``CommonOptionsParser`` instance. Just use it like you would a normal ``OptionParser`` in an independent script. Note that it offers several methods to add common options: ``--album``, ``--path`` and ``--format``. This feature is versatile and extensively documented, try ``pydoc beets.ui.CommonOptionsParser`` for more information. ================================================ FILE: docs/dev/plugins/events.rst ================================================ .. _plugin_events: Listen for Events ================= .. currentmodule:: beets.plugins Event handlers allow plugins to hook into whenever something happens in beets' operations. For instance, a plugin could write a log message every time an album is successfully autotagged or update MPD's index whenever the database is changed. You can "listen" for events using :py:meth:`BeetsPlugin.register_listener`. Here's an example: .. code-block:: python from beets.plugins import BeetsPlugin def loaded(): print("Plugin loaded!") class SomePlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener("pluginload", loaded) Note that if you want to access an attribute of your plugin (e.g. ``config`` or ``log``) you'll have to define a method and not a function. Here is the usual registration process in this case: .. code-block:: python from beets.plugins import BeetsPlugin class SomePlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener("pluginload", self.loaded) def loaded(self): self._log.info("Plugin loaded!") .. rubric:: Plugin Events ``pluginload`` :Parameters: (none) :Description: Called after all plugins have been loaded after the ``beet`` command starts. ``import`` :Parameters: ``lib`` (|Library|), ``paths`` (list of path strings) :Description: Called after the ``import`` command finishes. ``album_imported`` :Parameters: ``lib`` (|Library|), ``album`` (|Album|) :Description: Called every time the importer finishes adding an album to the library. ``album_removed`` :Parameters: ``lib`` (|Library|), ``album`` (|Album|) :Description: Called every time an album is removed from the library (even when its files are not deleted from disk). ``item_copied`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called whenever an item file is copied. ``item_imported`` :Parameters: ``lib`` (|Library|), ``item`` (|Item|) :Description: Called every time the importer adds a singleton to the library (not called for full-album imports). ``before_item_imported`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object immediately before it is imported. ``before_item_moved`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object immediately before its file is moved. ``item_moved`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever its file is moved. ``item_linked`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a symlink is created for a file. ``item_hardlinked`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a hardlink is created for a file. ``item_reflinked`` :Parameters: ``item`` (|Item|), ``source`` (path), ``destination`` (path) :Description: Called with an ``Item`` object whenever a reflink is created for a file. ``item_removed`` :Parameters: ``item`` (|Item|) :Description: Called with an ``Item`` object every time an item (singleton or part of an album) is removed from the library (even when its file is not deleted from disk). ``write`` :Parameters: ``item`` (|Item|), ``path`` (path), ``tags`` (dict) :Description: Called just before a file's metadata is written to disk. Handlers may modify ``tags`` or raise ``library.FileOperationError`` to abort. ``after_write`` :Parameters: ``item`` (|Item|) :Description: Called after a file's metadata is written to disk. ``import_task_created`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called immediately after an import task is initialized. May return a list (possibly empty) of replacement tasks. ``import_task_start`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before an import task begins processing. ``import_task_apply`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after metadata changes have been applied in an import task (on the UI thread; keep fast). Prefer a pipeline stage otherwise (see :ref:`plugin-stage`). ``import_task_before_choice`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after candidate search and before deciding how to import. May return an importer action (only one handler may return non-None). ``import_task_choice`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after a decision has been made about an import task. Use ``task.choice_flag`` to inspect or change the action. ``import_task_files`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called after filesystem manipulation (copy/move/write) for an import task. ``library_opened`` :Parameters: ``lib`` (|Library|) :Description: Called after beets starts and initializes the main Library object. ``database_change`` :Parameters: ``lib`` (|Library|), ``model`` (|Model|) :Description: A modification has been made to the library database (may not yet be committed). ``cli_exit`` :Parameters: ``lib`` (|Library|) :Description: Called just before the ``beet`` command-line program exits. ``import_begin`` :Parameters: ``session`` (|ImportSession|) :Description: Called just before a ``beet import`` session starts. ``trackinfo_received`` :Parameters: ``info`` (|TrackInfo|) :Description: Called after metadata for a track is fetched (e.g., from MusicBrainz). Handlers can modify the tags seen by later pipeline stages or adjustments (e.g., ``mbsync``). ``albuminfo_received`` :Parameters: ``info`` (|AlbumInfo|) :Description: Like ``trackinfo_received`` but for album-level metadata. ``album_matched`` :Parameters: ``match`` (``AlbumMatch``) :Description: Called each time an ``AlbumMatch`` candidate is created while importing. This applies to both ID-driven and text-search matching. Missing and extra tracks, if any, are included in the match. ``before_choose_candidate`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before prompting the user during interactive import. May return a list of ``PromptChoices`` to append to the prompt (see :ref:`append_prompt_choices`). ``mb_track_extract`` :Parameters: ``data`` (dict) :Description: Called after metadata is obtained from MusicBrainz for a track. Must return a (possibly empty) dict of additional ``field: value`` pairs to apply (overwriting existing fields). ``mb_album_extract`` :Parameters: ``data`` (dict) :Description: Like ``mb_track_extract`` but for album tags. Overwrites tags set at the track level with the same field. The included ``mpdupdate`` plugin provides an example use case for event listeners. ================================================ FILE: docs/dev/plugins/index.rst ================================================ Plugin Development ================== Beets plugins are Python modules or packages that extend the core functionality of beets. The plugin system is designed to be flexible, allowing developers to add virtually any type of features to beets. For instance you can create plugins that add new commands to the command-line interface, listen for events in the beets lifecycle or extend the autotagger with new metadata sources. .. _basic-plugin-setup: Basic Plugin Setup ------------------ A beets plugin is just a Python module or package inside the ``beetsplug`` namespace [1]_ package. To create the basic plugin layout, create a directory called ``beetsplug`` and add either your plugin module: .. code-block:: shell beetsplug/ └── myawesomeplugin.py or your plugin subpackage .. code-block:: shell beetsplug/ └── myawesomeplugin/ ├── __init__.py └── myawesomeplugin.py .. attention:: You do not need to add an ``__init__.py`` file to the ``beetsplug`` directory. Python treats your plugin as a namespace package automatically, thus we do not depend on ``pkgutil``-based setup in the ``__init__.py`` file anymore. The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal plugin without any functionality would look like this: .. code-block:: python # beetsplug/myawesomeplugin.py from beets.plugins import BeetsPlugin class MyAwesomePlugin(BeetsPlugin): pass .. attention:: If your plugin is composed of intermediate |BeetsPlugin| subclasses, make sure that your plugin is defined *last* in the namespace. We only load the last subclass of |BeetsPlugin| we find in your plugin namespace. To use your new plugin, you need to package [3]_ your plugin and install it into your ``beets`` (virtual) environment. To enable your plugin, add it it to the beets configuration .. code-block:: yaml # config.yaml plugins: - myawesomeplugin and you're good to go! .. [1] Check out `this article`_ and `this Stack Overflow question`_ if you haven't heard about namespace packages. .. [2] Abstract base classes allow us to define a contract which any plugin must follow. This is a common paradigm in object-oriented programming, and it helps to ensure that plugins are implemented in a consistent way. For more information, see for example pep-3119_. .. [3] There are a variety of packaging tools available for python, for example you can use poetry_, setuptools_ or hatchling_. .. _hatchling: https://hatch.pypa.io/latest/config/build/#build-system .. _pep-3119: https://peps.python.org/pep-3119/#rationale .. _poetry: https://python-poetry.org/docs/pyproject/#packages .. _setuptools: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#finding-simple-packages .. _this article: https://realpython.com/python-namespace-package/#setting-up-some-namespace-packages .. _this stack overflow question: https://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/27586272#27586272 More information ---------------- For more information on writing plugins, feel free to check out the following resources: .. toctree:: :maxdepth: 3 :includehidden: commands events autotagger other/index ================================================ FILE: docs/dev/plugins/other/config.rst ================================================ Read Configuration Options ========================== Plugins can configure themselves using the ``config.yaml`` file. You can read configuration values in two ways. The first is to use ``self.config`` within your plugin class. This gives you a view onto the configuration values in a section with the same name as your plugin's module. For example, if your plugin is in ``greatplugin.py``, then ``self.config`` will refer to options under the ``greatplugin:`` section of the config file. For example, if you have a configuration value called "foo", then users can put this in their ``config.yaml``: :: greatplugin: foo: bar To access this value, say ``self.config['foo'].get()`` at any point in your plugin's code. The ``self.config`` object is a *view* as defined by the Confuse_ library. .. _confuse: https://confuse.readthedocs.io/en/latest/ If you want to access configuration values *outside* of your plugin's section, import the ``config`` object from the ``beets`` module. That is, just put ``from beets import config`` at the top of your plugin and access values from there. If your plugin provides configuration values for sensitive data (e.g., passwords, API keys, ...), you should add these to the config so they can be redacted automatically when users dump their config. This can be done by setting each value's ``redact`` flag, like so: :: self.config['password'].redact = True ================================================ FILE: docs/dev/plugins/other/fields.rst ================================================ Flexible Field Types ==================== If your plugin uses flexible fields to store numbers or other non-string values, you can specify the types of those fields. A rating plugin, for example, might want to declare that the ``rating`` field should have an integer type: .. code-block:: python from beets.plugins import BeetsPlugin from beets.dbcore import types class RatingPlugin(BeetsPlugin): item_types = {"rating": types.INTEGER} @property def album_types(self): return {"rating": types.INTEGER} A plugin may define two attributes: ``item_types`` and ``album_types``. Each of those attributes is a dictionary mapping a flexible field name to a type instance. You can find the built-in types in the ``beets.dbcore.types`` and ``beets.library`` modules or implement your own type by inheriting from the ``Type`` class. Specifying types has several advantages: - Code that accesses the field like ``item['my_field']`` gets the right type (instead of just a string). - You can use advanced queries (like :ref:`ranges <numericquery>`) from the command line. - User input for flexible fields may be validated and converted. - Items missing the given field can use an appropriate null value for querying and sorting purposes. ================================================ FILE: docs/dev/plugins/other/import.rst ================================================ .. _plugin-stage: Add Import Pipeline Stages ========================== Many plugins need to add high-latency operations to the import workflow. For example, a plugin that fetches lyrics from the Web would, ideally, not block the progress of the rest of the importer. Beets allows plugins to add stages to the parallel import pipeline. Each stage is run in its own thread. Plugin stages run after metadata changes have been applied to a unit of music (album or track) and before file manipulation has occurred (copying and moving files, writing tags to disk). Multiple stages run in parallel but each stage processes only one task at a time and each task is processed by only one stage at a time. Plugins provide stages as functions that take two arguments: ``config`` and ``task``, which are ``ImportSession`` and ``ImportTask`` objects (both defined in ``beets.importer``). Add such a function to the plugin's ``import_stages`` field to register it: .. code-block:: python from beets.importer import ImportSession, ImportTask from beets.plugins import BeetsPlugin class ExamplePlugin(BeetsPlugin): def __init__(self): super().__init__() self.import_stages = [self.stage] def stage(self, session: ImportSession, task: ImportTask): print("Importing something!") It is also possible to request your function to run early in the pipeline by adding the function to the plugin's ``early_import_stages`` field instead: .. code-block:: python self.early_import_stages = [self.stage] .. _extend-query: Extend the Query Syntax ----------------------- You can add new kinds of queries to beets' :doc:`query syntax </reference/query>`. There are two ways to add custom queries: using a prefix and using a name. Prefix-based query extension can apply to *any* field, while named queries are not associated with any field. For example, beets already supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. For either kind of query extension, define a subclass of the ``Query`` type from the ``beets.dbcore.query`` module. Then: - To define a prefix-based query, define a ``queries`` method in your plugin class. Return from this method a dictionary mapping prefix strings to query classes. - To define a named query, defined dictionaries named either ``item_queries`` or ``album_queries``. These should map names to query types. So if you use ``{ "foo": FooQuery }``, then the query ``foo:bar`` will construct a query like ``FooQuery("bar")``. For prefix-based queries, you will want to extend ``FieldQuery``, which implements string comparisons on fields. To use it, create a subclass inheriting from that class and override the ``value_match`` class method. (Remember the ``@classmethod`` decorator!) The following example plugin declares a query using the ``@`` prefix to delimit exact string matches. The plugin will be used if we issue a command like ``beet ls @something`` or ``beet ls artist:@something``: .. code-block:: python from beets.plugins import BeetsPlugin from beets.dbcore import FieldQuery class ExactMatchQuery(FieldQuery): @classmethod def value_match(self, pattern, val): return pattern == val class ExactMatchPlugin(BeetsPlugin): def queries(self): return {"@": ExactMatchQuery} ================================================ FILE: docs/dev/plugins/other/index.rst ================================================ Further Reading =============== For more information on writing plugins, feel free to check out the following resources: .. toctree:: :maxdepth: 2 config templates mediafile import fields logging prompts ================================================ FILE: docs/dev/plugins/other/logging.rst ================================================ .. _plugin-logging: Logging ======= Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the `standard Python logging module`_. The logger is set up to `PEP 3101`_, str.format-style string formatting. So you can write logging calls like this: .. code-block:: python self._log.debug("Processing {0.title} by {0.artist}", item) .. _pep 3101: https://peps.python.org/pep-3101/ .. _standard python logging module: https://docs.python.org/3/library/logging.html When beets is in verbose mode, plugin messages are prefixed with the plugin name to make them easier to see. Which messages will be logged depends on the logging level and the action performed: - Inside import stages and event handlers, the default is ``WARNING`` messages and above. - Everywhere else, the default is ``INFO`` or above. The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags lowers the level by a notch. That means that, with a single ``-v`` flag, event handlers won't have their ``DEBUG`` messages displayed, but command functions (for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will be displayed everywhere. This addresses a common pattern where plugins need to use the same code for a command and an import stage, but the command needs to print more messages than the import stage. (For example, you'll want to log "found lyrics for this song" when you're run explicitly as a command, but you don't want to noisily interrupt the importer interface when running automatically.) ================================================ FILE: docs/dev/plugins/other/mediafile.rst ================================================ Extend MediaFile ================ MediaFile_ is the file tag abstraction layer that beets uses to make cross-format metadata manipulation simple. Plugins can add fields to MediaFile to extend the kinds of metadata that they can easily manage. The ``MediaFile`` class uses ``MediaField`` descriptors to provide access to file tags. If you have created a descriptor you can add it through your plugins :py:meth:`beets.plugins.BeetsPlugin.add_media_field` method. .. _mediafile: https://mediafile.readthedocs.io/en/latest/ Here's an example plugin that provides a meaningless new field "foo": .. code-block:: python class FooPlugin(BeetsPlugin): def __init__(self): field = mediafile.MediaField( mediafile.MP3DescStorageStyle("foo"), mediafile.StorageStyle("foo") ) self.add_media_field("foo", field) FooPlugin() item = Item.from_path("/path/to/foo/tag.mp3") assert item["foo"] == "spam" item["foo"] == "ham" item.write() # The "foo" tag of the file is now "ham" ================================================ FILE: docs/dev/plugins/other/prompts.rst ================================================ .. _append_prompt_choices: Append Prompt Choices ===================== Plugins can also append choices to the prompt presented to the user during an import session. To do so, add a listener for the ``before_choose_candidate`` event, and return a list of ``PromptChoices`` that represent the additional choices that your plugin shall expose to the user: .. code-block:: python from beets.plugins import BeetsPlugin from beets.util import PromptChoice class ExamplePlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.before_choose_candidate_event ) def before_choose_candidate_event(self, session, task): return [ PromptChoice("p", "Print foo", self.foo), PromptChoice("d", "Do bar", self.bar), ] def foo(self, session, task): print('User has chosen "Print foo"!') def bar(self, session, task): print('User has chosen "Do bar"!') The previous example modifies the standard prompt: .. code-block:: shell # selection (default 1), Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort? by appending two additional options (``Print foo`` and ``Do bar``): .. code-block:: shell # selection (default 1), Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort, Print foo, Do bar? If the user selects a choice, the ``callback`` attribute of the corresponding ``PromptChoice`` will be called. It is the responsibility of the plugin to check for the status of the import session and decide the choices to be appended: for example, if a particular choice should only be presented if the album has no candidates, the relevant checks against ``task.candidates`` should be performed inside the plugin's ``before_choose_candidate_event`` accordingly. Please make sure that the short letter for each of the choices provided by the plugin is not already in use: the importer will emit a warning and discard all but one of the choices using the same letter, giving priority to the core importer prompt choices. As a reference, the following characters are used by the choices on the core importer prompt, and hence should not be used: ``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. Additionally, the callback function can optionally specify the next action to be performed by returning a ``importer.Action`` value. It may also return a ``autotag.Proposal`` value to update the set of current proposals to be considered. ================================================ FILE: docs/dev/plugins/other/templates.rst ================================================ Add Path Format Functions and Fields ==================================== Beets supports *function calls* in its path format syntax (see :doc:`/reference/pathformat`). Beets includes a few built-in functions, but plugins can register new functions by adding them to the ``template_funcs`` dictionary. Here's an example: .. code-block:: python class MyPlugin(BeetsPlugin): def __init__(self): super().__init__() self.template_funcs["initial"] = _tmpl_initial def _tmpl_initial(text: str) -> str: if text: return text[0].upper() else: return "" This plugin provides a function ``%initial`` to path templates where ``%initial{$artist}`` expands to the artist's initial (its capitalized first character). Plugins can also add template *fields*, which are computed values referenced as ``$name`` in templates. To add a new field, add a function that takes an ``Item`` object to the ``template_fields`` dictionary on the plugin object. Here's an example that adds a ``$disc_and_track`` field: .. code-block:: python class MyPlugin(BeetsPlugin): def __init__(self): super().__init__() self.template_fields["disc_and_track"] = _tmpl_disc_and_track def _tmpl_disc_and_track(item: Item) -> str: """Expand to the disc number and track number if this is a multi-disc release. Otherwise, just expands to the track number. """ if item.disctotal > 1: return "%02i.%02i" % (item.disc, item.track) else: return "%02i" % (item.track) With this plugin enabled, templates can reference ``$disc_and_track`` as they can any standard metadata field. This field works for *item* templates. Similarly, you can register *album* template fields by adding a function accepting an ``Album`` argument to the ``album_template_fields`` dict. ================================================ FILE: docs/extensions/conf.py ================================================ """Sphinx extension for simple configuration value documentation.""" from __future__ import annotations from typing import TYPE_CHECKING, Any, ClassVar from docutils import nodes from docutils.parsers.rst import directives from sphinx import addnodes from sphinx.directives import ObjectDescription from sphinx.domains import Domain, ObjType from sphinx.roles import XRefRole from sphinx.util.nodes import make_refnode if TYPE_CHECKING: from collections.abc import Iterable, Sequence from docutils.nodes import Element from docutils.parsers.rst.states import Inliner from sphinx.addnodes import desc_signature, pending_xref from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.environment import BuildEnvironment from sphinx.util.typing import ExtensionMetadata, OptionSpec class Conf(ObjectDescription[str]): """Directive for documenting a single configuration value.""" option_spec: ClassVar[OptionSpec] = { "default": directives.unchanged, } def handle_signature(self, sig: str, signode: desc_signature) -> str: """Process the directive signature (the config name).""" signode += addnodes.desc_name(sig, sig) # Add default value if provided if "default" in self.options: signode += nodes.Text(" ") default_container = nodes.inline("", "") default_container += nodes.Text("(default: ") default_container += nodes.literal("", self.options["default"]) default_container += nodes.Text(")") signode += default_container return sig def add_target_and_index( self, name: str, sig: str, signode: desc_signature ) -> None: """Add cross-reference target and index entry.""" target = f"conf-{name}" if target not in self.state.document.ids: signode["ids"].append(target) self.state.document.note_explicit_target(signode) # A unique full name which includes the document name index_name = f"{self.env.docname.replace('/', '.')}:{name}" # Register with the conf domain domain = self.env.get_domain("conf") domain.data["objects"][index_name] = (self.env.docname, target) # Add to index self.indexnode["entries"].append( ("single", f"{name} (configuration value)", target, "", None) ) class ConfDomain(Domain): """Domain for simple configuration values.""" name = "conf" label = "Simple Configuration" object_types = {"conf": ObjType("conf", "conf")} # noqa: RUF012 directives = {"conf": Conf} # noqa: RUF012 roles = {"conf": XRefRole()} # noqa: RUF012 initial_data: dict[str, Any] = {"objects": {}} # noqa: RUF012 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: """Return an iterable of object tuples for the inventory.""" for name, (docname, targetname) in self.data["objects"].items(): # Remove the document name prefix for display display_name = name.split(":")[-1] yield (name, display_name, "conf", docname, targetname, 1) def resolve_xref( self, env: BuildEnvironment, fromdocname: str, builder: Builder, typ: str, target: str, node: pending_xref, contnode: Element, ) -> Element | None: if entry := self.data["objects"].get(target): docname, targetid = entry return make_refnode( builder, fromdocname, docname, targetid, contnode ) return None # sphinx.util.typing.RoleFunction def conf_role( name: str, rawtext: str, text: str, lineno: int, inliner: Inliner, /, options: dict[str, Any] | None = None, content: Sequence[str] = (), ) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Role for referencing configuration values.""" node = addnodes.pending_xref( "", refdomain="conf", reftype="conf", reftarget=text, refwarn=True, **(options or {}), ) node += nodes.literal(text, text.split(":")[-1]) return [node], [] def setup(app: Sphinx) -> ExtensionMetadata: app.add_domain(ConfDomain) # register a top-level directive so users can use ".. conf:: ..." app.add_directive("conf", Conf) # Register role with short name app.add_role("conf", conf_role) return { "version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True, } ================================================ FILE: docs/faq.rst ================================================ FAQ === Here are some answers to frequently-asked questions from IRC and elsewhere. Got a question that isn't answered here? Try the `discussion board`_, or :ref:`filing an issue <bugs>` in the bug tracker. .. _discussion board: https://github.com/beetbox/beets/discussions/ .. _mailing list: https://groups.google.com/group/beets-users .. contents:: :local: :depth: 2 How do I… --------- .. _move: …rename my files according to a new path format configuration? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Just run the :ref:`move-cmd` command. Use a :doc:`query </reference/query>` to rename a subset of your music or leave the query off to rename everything. .. _asispostfacto: …find all the albums I imported "as-is"? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Enable the :ref:`import log <import_log>` to automatically record whenever you skip an album or accept one "as-is". Alternatively, you can find all the albums in your library that are missing MBIDs using a command like this: :: beet ls -a mb_albumid::^$ Assuming your files didn't have MBIDs already, then this will roughly correspond to those albums that didn't get autotagged. .. _discdir: …create "Disc N" directories for multi-disc albums? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Use the :doc:`/plugins/inline` along with the ``%if{}`` function to accomplish this: :: plugins: inline paths: default: $albumartist/$album%aunique{}/%if{$multidisc,Disc $disc/}$track $title item_fields: multidisc: 1 if disctotal > 1 else 0 This ``paths`` configuration only contains the ``default`` key: it leaves the ``comp`` and ``singleton`` keys as their default values, as documented in :ref:`path-format-config`. To create "Disc N" directories for compilations and singletons, you will need to specify similar templates for those keys as well. .. _multidisc: …import a multi-disc album? ~~~~~~~~~~~~~~~~~~~~~~~~~~~ As of 1.0b11, beets tags multi-disc albums as a *single unit*. To get a good match, it needs to treat all of the album's parts together as a single release. To help with this, the importer uses a simple heuristic to guess when a directory represents a multi-disc album that's been divided into multiple subdirectories. When it finds a situation like this, it collapses all of the items in the subdirectories into a single release for tagging. The heuristic works by looking at the names of directories. If multiple subdirectories of a common parent directory follow the pattern "(title) disc (number) (...)" and the *prefix* (everything up to the number) is the same, the directories are collapsed together. One of the key words "disc" or "CD" must be present to make this work. If you have trouble tagging a multi-disc album, consider the ``--flat`` flag (which treats a whole tree as a single album) or just putting all the tracks into a single directory to force them to be tagged together. .. _mbid: …enter a MusicBrainz ID? ~~~~~~~~~~~~~~~~~~~~~~~~ An MBID looks like one of these: - ``https://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87`` - ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3`` Beets can recognize either the hex-with-dashes UUID-style string or the full URL that contains it (as of 1.0b11). You can get these IDs by `searching on the MusicBrainz web site <https://musicbrainz.org/>`__ and going to a *release* page (when tagging full albums) or a *recording* page (when tagging singletons). Then, copy the URL of the page and paste it into beets. Note that MusicBrainz has both "releases" and "release groups," which link together different versions of the same album. Use *release* IDs here. .. _upgrade: …upgrade to the latest version of beets? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Run a command like this: :: pip install -U beets The ``-U`` flag tells pip_ to upgrade beets to the latest version. If you want a specific version, you can specify with using ``==`` like so: :: pip install beets==1.0rc2 .. _src: …run the latest source version of beets? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Beets sees regular releases (about every six weeks or so), but sometimes it's helpful to run on the "bleeding edge". To run the latest source: 1. Uninstall beets. If you installed using ``pip``, you can just run ``pip uninstall beets``. 2. Install from source. Choose one of these methods: - Directly from GitHub using ``python -m pip install git+https://github.com/beetbox/beets.git`` command. Depending on your system, you may need to use ``pip3`` and ``python3`` instead of ``pip`` and ``python`` respectively. - Use ``pip`` to install the latest snapshot tarball. Type: ``pip install https://github.com/beetbox/beets/tarball/master`` - Use ``pip`` to install an "editable" version of beets based on an automatic source checkout. For example, run ``pip install -e git+https://github.com/beetbox/beets#egg=beets`` to clone beets and install it, allowing you to modify the source in-place to try out changes. - Clone source code and install it in editable mode .. code-block:: shell git clone https://github.com/beetbox/beets.git poetry install This approach lets you decide where the source is stored, with any changes immediately reflected in your environment. More details about the beets source are available on the :doc:`developer documentation </dev/index>` pages. .. _bugs: …report a bug in beets? ----------------------- We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please follow these guidelines when reporting an issue: - Most importantly: if beets is crashing, please `include the traceback <https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin <https://www.toptal.com/developers/hastebin>`__), especially when communicating over IRC. - Turn on beets' debug output (using the -v option: for example, ``beet -v import ...``) and include that with your bug report. Look through this verbose output for any red flags that might point to the problem. - If you can, try installing the latest beets source code to see if the bug is fixed in an unreleased version. You can also look at the :doc:`latest changelog entries </changelog>` for descriptions of the problem you're seeing. - Try to narrow your problem down to something specific. Is a particular plugin causing the problem? (You can disable plugins to see whether the problem goes away.) Is a some music file or a single album leading to the crash? (Try importing individual albums to determine which one is causing the problem.) Is some entry in your configuration file causing it? Et cetera. - If you do narrow the problem down to a particular audio file or album, include it with your bug report so the developers can run tests. If you've never reported a bug before, Mozilla has some well-written `general guidelines for good bug reports`_. .. _find-config: .. _general guidelines for good bug reports: https://bugzilla.mozilla.org/page.cgi?id=bug-writing.html .. _issue tracker: https://github.com/beetbox/beets/issues …find the configuration file (config.yaml)? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You create this file yourself; beets just reads it. See :doc:`/reference/config`. .. _special-chars: …avoid using special characters in my filenames? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Use the ``%asciify{}`` function in your path formats. See :ref:`template-functions`. .. _move-dir: …point beets at a new music directory? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you want to move your music from one directory to another, the best way is to let beets do it for you. First, edit your configuration and set the ``directory`` setting to the new place. Then, type ``beet move`` to have beets move all your files. If you've already moved your music *outside* of beets, you have a few options: - Move the music back (with an ordinary ``mv``) and then use the above steps. - Delete your database and re-create it from the new paths using ``beet import -AWC``. - Resort to manually modifying the SQLite database (not recommended). Why does beets… --------------- .. _nomatch: …complain that it can't find a match? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ There are a number of possibilities: - First, make sure you have at least one autotagger extension/plugin enabled. See :ref:`autotagger_extensions` for a list of valid plugins. - Check that the album is in `the MusicBrainz database <https://musicbrainz.org/>`__. You can search on their site to make sure it's cataloged there. (If not, anyone can edit MusicBrainz---so consider adding the data yourself.) - If the album in question is a multi-disc release, see the relevant FAQ answer above. - The music files' metadata might be insufficient. Try using the "enter search" or "enter ID" options to help the matching process find the right MusicBrainz entry. - If you have a lot of files that are missing metadata, consider using :doc:`acoustic fingerprinting </plugins/chroma>` or :doc:`filename-based guesses </plugins/fromfilename>` for that music. If none of these situations apply and you're still having trouble tagging something, please :ref:`file a bug report <bugs>`. .. _plugins: …appear to be missing some plugins? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Please make sure you're using the latest version of beets---you might be using a version earlier than the one that introduced the plugin. In many cases, the plugin may be introduced in beets "trunk" (the latest source version) and might not be released yet. Take a look at :doc:`the changelog </changelog>` to see which version added the plugin. (You can type ``beet version`` to check which version of beets you have installed.) If you want to live on the bleeding edge and use the latest source version of beets, you can check out the source (see :ref:`the relevant question <src>`). To see the beets documentation for your version (and avoid confusion with new features in trunk), select your version from the menu in the sidebar. .. _kill: …ignore control-C during an import? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Typing a ^C (control-C) control sequence will not halt beets' multithreaded importer while it is waiting at a prompt for user input. Instead, hit "return" (dismissing the prompt) after typing ^C. Alternatively, just type a "b" for "aBort" at most prompts. Typing ^C *will* work if the importer interface is between prompts. Also note that beets may take some time to quit after ^C is typed; it tries to clean up after itself briefly even when canceled. (For developers: this is because the UI thread is blocking on ``input`` and cannot be interrupted by the main thread, which is trying to close all pipeline stages in the exception handler by setting a flag. There is no simple way to remedy this.) .. _id3v24: …not change my ID3 tags? ~~~~~~~~~~~~~~~~~~~~~~~~ Beets writes ID3v2.4_ tags by default. Some software, including Windows (i.e., Windows Explorer and Windows Media Player) and `id3lib/id3v2 <https://sourceforge.net/projects/id3v2/>`__, don't support v2.4 tags. When using 2.4-unaware software, it might look like the tags are unmodified or missing completely. To enable ID3v2.3 tags, enable the :ref:`id3v23` config option. .. _id3v2.4: https://id3.org/id3v2.4.0-structure .. _invalid: …complain that a file is "unreadable"? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Beets will log a message like "unreadable file: /path/to/music.mp3" when it encounters files that *look* like music files (according to their extension) but seem to be broken. Most of the time, this is because the file is corrupted. To check whether the file is intact, try opening it in another media player (e.g., `VLC <https://www.videolan.org/vlc/index.html>`__) to see whether it can read the file. You can also use specialized programs for checking file integrity---for example, type ``metaflac --list music.flac`` to check FLAC files. If beets still complains about a file that seems to be valid, `open a new ticket`_ and we'll look into it. There's always a possibility that there's a bug "upstream" in the `Mutagen <https://github.com/quodlibet/mutagen>`__ library used by beets, in which case we'll forward the bug to that project's tracker. .. _importhang: …seem to "hang" after an import finishes? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Probably not. Beets uses a *multithreaded importer* that overlaps many different activities: it can prompt you for decisions while, in the background, it talks to MusicBrainz and copies files. This means that, even after you make your last decision, there may be a backlog of files to be copied into place and tags to be written. (Plugin tasks, like looking up lyrics and genres, also run at this time.) If beets pauses after you see all the albums go by, have patience. .. _replaceq: …put a bunch of underscores in my filenames? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When naming files, beets replaces certain characters to avoid causing problems on the filesystem. For example, leading dots can confusingly hide files on Unix and several non-alphanumeric characters are forbidden on Windows. The :ref:`replace` config option controls which replacements are made. By default, beets makes filenames safe for all known platforms by replacing several patterns with underscores. This means that, even on Unix, filenames are made Windows-safe so that network filesystems (such as SMB) can be used safely. Most notably, Windows forbids trailing dots, so a folder called "M.I.A." will be rewritten to "M.I.A\_" by default. Change the ``replace`` config if you don't want this behavior and don't need Windows-safe names. .. _pathq: …say "command not found"? ~~~~~~~~~~~~~~~~~~~~~~~~~ You need to put the ``beet`` program on your system's search path. If you installed using pip, the command ``pip show -f beets`` can show you where ``beet`` was placed on your system. If you need help extending your ``$PATH``, try `this Super User answer`_. .. _open a new ticket: https://github.com/beetbox/beets/issues/new?template=bug-report.md .. _pip: https://pip.pypa.io/en/stable/ .. _this super user answer: https://superuser.com/questions/284342/what-are-path-and-other-environment-variables-and-how-can-i-set-or-use-them/284361#284361 ================================================ FILE: docs/guides/advanced.rst ================================================ Advanced Awesomeness ==================== So you have beets up and running and you've started :doc:`importing your music </guides/tagger>`. There's a lot more that beets can do now that it has cataloged your collection. Here's a few features to get you started. Most of these tips involve :doc:`plugins </plugins/index>` and fiddling with beets' :doc:`configuration </reference/config>`. So use your favorite text editor to create a config file before you continue. Fetch album art, genres, and lyrics ----------------------------------- Beets can help you fill in more than just the basic taxonomy metadata that comes from MusicBrainz. Plugins can provide :doc:`album art </plugins/fetchart>`, :doc:`lyrics </plugins/lyrics>`, and :doc:`genres </plugins/lastgenre>` from databases around the Web. If you want beets to get any of this data automatically during the import process, just enable any of the three relevant plugins (see :doc:`/plugins/index`). For example, put this line in your :doc:`config file </reference/config>` to enable all three: :: plugins: fetchart lyrics lastgenre Each plugin also has a command you can run to fetch data manually. For example, if you want to get lyrics for all the Beatles tracks in your collection, just type ``beet lyrics beatles`` after enabling the plugin. Read more about using each of these plugins: - :doc:`/plugins/fetchart` (and its accompanying :doc:`/plugins/embedart`) - :doc:`/plugins/lyrics` - :doc:`/plugins/lastgenre` Customize your file and folder names ------------------------------------ Beets uses an extremely flexible template system to name the folders and files that organize your music in your filesystem. Take a look at :ref:`path-format-config` for the basics: use fields like ``$year`` and ``$title`` to build up a naming scheme. But if you need more flexibility, there are two features you need to know about: - :ref:`Template functions <template-functions>` are simple expressions you can use in your path formats to add logic to your names. For example, you can get an artist's first initial using ``%upper{%left{$albumartist,1}}``. - If you need more flexibility, the :doc:`/plugins/inline` lets you write snippets of Python code that generate parts of your filenames. The equivalent code for getting an artist initial with the *inline* plugin looks like ``initial: albumartist[0].upper()``. If you already have music in your library and want to update their names according to a new scheme, just run the :ref:`move-cmd` command to rename everything. Stream your music to another computer ------------------------------------- Sometimes it can be really convenient to store your music on one machine and play it on another. For example, I like to keep my music on a server at home, but play it at work (without copying my whole library locally). The :doc:`/plugins/web` makes streaming your music easy---it's sort of like having your own personal Spotify. First, enable the ``web`` plugin (see :doc:`/plugins/index`). Run the server by typing ``beet web`` and head to http://localhost:8337 in a browser. You can browse your collection with queries and, if your browser supports it, play music using HTML5 audio. Transcode music files for media players --------------------------------------- Do you ever find yourself transcoding high-quality rips to a lower-bitrate, lossy format for your phone or music player? Beets can help with that. You'll first need to install ffmpeg_. Then, enable beets' :doc:`/plugins/convert`. Set a destination directory in your :doc:`config file </reference/config>` like so: :: convert: dest: ~/converted_music Then, use the command ``beet convert QUERY`` to transcode everything matching the query and drop the resulting files in that directory, named according to your path formats. For example, ``beet convert long winters`` will move over everything by the Long Winters for listening on the go. The plugin has many more dials you can fiddle with to get your conversions how you like them. Check out :doc:`its documentation </plugins/convert>`. .. _ffmpeg: https://www.ffmpeg.org Store any data you like ----------------------- The beets database keeps track of a long list of :ref:`built-in fields <itemfields>`, but you're not limited to just that list. Say, for example, that you like to categorize your music by the setting where it should be played. You can invent a new ``context`` attribute to store this. Set the field using the :ref:`modify-cmd` command: :: beet modify context=party artist:'beastie boys' By default, beets will show you the changes that are about to be applied and ask if you really want to apply them to all, some or none of the items or albums. You can type y for "yes", n for "no", or s for "select". If you choose the latter, the command will prompt you for each individual matching item or album. Then :doc:`query </reference/query>` your music just as you would with any other field: :: beet ls context:mope You can even use these fields in your filenames (see :ref:`path-format-config`). And, unlike :ref:`built-in fields <itemfields>`, such fields can be removed: :: beet modify context! artist:'beastie boys' Read more than you ever wanted to know about the *flexible attributes* feature `on the beets blog`_. .. _on the beets blog: https://beets.io/blog/flexattr.html Choose a path style manually for some music ------------------------------------------- Sometimes, you need to categorize some songs differently in your file system. For example, you might want to group together all the music you don't really like, but keep around to play for friends and family. This is, of course, impossible to determine automatically using metadata from MusicBrainz. Instead, use a flexible attribute (see above) to store a flag on the music you want to categorize, like so: :: beet modify bad=1 christmas Then, you can query on this field in your path formats to sort this music differently. Put something like this in your configuration file: :: paths: bad:1: Bad/$artist/$title Used together, flexible attributes and path format conditions let you sort your music by any criteria you can imagine. Automatically add new music to your library ------------------------------------------- As a command-line tool, beets is perfect for automated operation via a cron job or the like. To use it this way, you might want to use these options in your :doc:`config file </reference/config>`: .. code-block:: yaml import: incremental: yes quiet: yes log: /path/to/log.txt The :ref:`incremental` option will skip importing any directories that have been imported in the past. :ref:`quiet` avoids asking you any questions (since this will be run automatically, no input is possible). You might also want to use the :ref:`quiet_fallback` options to configure what should happen when no near-perfect match is found -- this option depends on your level of paranoia. Finally, :ref:`import_log` will make beets record its decisions so you can come back later and see what you need to handle manually. The last step is to set up cron or some other automation system to run ``beet import /path/to/incoming/music``. Useful reports -------------- Since beets has a quite powerful query tool, this list contains some useful and powerful queries to run on your library. - See a list of all albums which have files which are 128 bit rate: :: beet list bitrate:128000 - See a list of all albums with the tracks listed in order of bit rate: :: beet ls -f '$bitrate $artist - $title' bitrate+ - See a list of albums and their formats: :: beet ls -f '$albumartist $album $format' | sort | uniq Note that ``beet ls --album -f '... $format'`` doesn't do what you want, because ``format`` is an item-level field, not an album-level one. If an album's tracks exist in multiple formats, the album will appear in the list once for each format. ================================================ FILE: docs/guides/index.rst ================================================ Guides ====== This section contains a couple of walkthroughs that will help you get familiar with beets. If you're new to beets, you'll want to begin with the :doc:`main` guide. .. toctree:: :maxdepth: 1 main installation tagger advanced ================================================ FILE: docs/guides/installation.rst ================================================ Installation ============ Beets requires `Python 3.10 or later`_. You can install it using pipx_ or pip_. .. _python 3.10 or later: https://www.python.org/downloads/ Using ``pipx`` or ``pip`` ------------------------- We recommend installing with pipx_ as it isolates beets and its dependencies from your system Python and other Python packages. This helps avoid dependency conflicts and keeps your system clean. .. <!-- start-quick-install --> .. tab-set:: .. tab-item:: pipx .. code-block:: console pipx install beets .. tab-item:: pip .. code-block:: console pip install beets .. tab-item:: pip (user install) .. code-block:: console pip install --user beets .. <!-- end-quick-install --> If you don't have pipx_ installed, you can follow the instructions on the `pipx installation page`_ to get it set up. .. _pip: https://pip.pypa.io/en/stable/ .. _pipx: https://pipx.pypa.io/stable .. _pipx installation page: https://pipx.pypa.io/stable/installation/ Managing Plugins with ``pipx`` ------------------------------ When using pipx_, you can install beets with built-in plugin dependencies using extras, inject third-party packages, and upgrade everything cleanly. Install beets with extras for built-in plugins: .. code-block:: console pipx install "beets[lyrics,lastgenre]" If you already have beets installed, reinstall with a new set of extras: .. code-block:: console pipx install --force "beets[lyrics,lastgenre]" Inject additional packages into the beets environment (useful for third-party plugins): .. code-block:: console pipx inject beets <package-name> To upgrade beets and all injected packages: .. code-block:: console pipx upgrade beets Installation FAQ ---------------- Windows Installation ~~~~~~~~~~~~~~~~~~~~ **Q: What's the process for installing on Windows?** Installing beets on Windows can be tricky. Following these steps might help you get it right: 1. `Install Python`_ (check "Add Python to PATH" skip to 3) 2. Ensure Python is in your ``PATH`` (add if needed): - Settings → System → About → Advanced system settings → Environment Variables - Edit "PATH" and add: `;C:\Python39;C:\Python39\Scripts` - *Guide: [Adding Python to PATH](https://realpython.com/add-python-to-path/)* 3. Now install beets by running: ``pip install beets`` 4. You're all set! Type ``beet version`` in a new command prompt to verify the installation. **Bonus: Windows Context Menu Integration** Windows users may also want to install a context menu item for importing files into beets. Download the beets.reg_ file and open it in a text file to make sure the paths to Python match your system. Then double-click the file add the necessary keys to your registry. You can then right-click a directory and choose "Import with beets". .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg .. _install pip: https://pip.pypa.io/en/stable/installing/ .. _install python: https://www.python.org/downloads/ ARM Installation ~~~~~~~~~~~~~~~~ **Q: Can I run beets on a Raspberry Pi or other ARM device?** Yes, but with some considerations: Beets on ARM devices is not recommended for Linux novices. If you are comfortable with troubleshooting tools like ``pip``, ``make``, and binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), you will be fine. We have `notes for ARM`_ and an `older ARM reference`_. Beets is generally developed on x86-64 based devices, and most plugins target that platform as well. .. _notes for arm: https://github.com/beetbox/beets/discussions/4910 .. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993 Package Manager Installation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Q: Can I install beets using my operating system's built-in package manager?** We generally don't recommend this route. OS package managers tend to ship outdated versions of beets, and installing third-party plugins into a system-managed environment ranges from awkward to impossible. You'll have a much better time with pipx_ or pip_ as described above. That said, if you know what you're doing and prefer your system package manager, here are the options available: - **Debian/Ubuntu** (`Debian <debian details_>`_, `Ubuntu <ubuntu details_>`_): ``apt-get install beets`` - **Arch Linux** (`extra <arch btw_>`_, `AUR dev <aur_>`_): ``pacman -S beets`` - **Alpine Linux** (`package <alpine package_>`_): ``apk add beets`` - **Void Linux** (`package <void package_>`_): ``xbps-install -S beets`` - **Gentoo Linux**: ``emerge beets`` (USE flags available for optional plugin deps) - **FreeBSD** (`port <freebsd_>`_): ``audio/beets`` - **OpenBSD** (`port <openbsd_>`_): ``pkg_add beets`` - **Fedora** (`package <dnf package_>`_): ``dnf install beets beets-plugins beets-doc`` - **Solus**: ``eopkg install beets`` - **NixOS** (`package <nixos_>`_): ``nix-env -i beets`` - **MacPorts**: ``port install beets`` or ``port install beets-full`` (includes third-party plugins) .. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets .. _arch btw: https://archlinux.org/packages/extra/any/beets/ .. _aur: https://aur.archlinux.org/packages/beets-git/ .. _debian details: https://tracker.debian.org/pkg/beets .. _dnf package: https://packages.fedoraproject.org/pkgs/beets/ .. _freebsd: https://www.freshports.org/audio/beets/ .. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/development/python-modules/beets .. _openbsd: https://openports.pl/path/audio/beets .. _ubuntu details: https://launchpad.net/ubuntu/+source/beets .. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets ================================================ FILE: docs/guides/main.rst ================================================ Getting Started =============== Welcome to beets_! This guide will help get started with improving and organizing your music collection. .. _beets: https://beets.io/ Quick Installation ------------------ Beets is distributed via PyPI_ and can be installed by most users with a single command: .. include:: installation.rst :start-after: <!-- start-quick-install --> :end-before: <!-- end-quick-install --> .. admonition:: Need more information? Having trouble with the commands above? Looking for information how to install plugins and keep Beets updated? See the :doc:`complete installation guide </guides/installation>` for: - Managing plugins with pipx - OS-specific installation notes - Package manager options .. _pypi: https://pypi.org/project/beets/ Basic Configuration ------------------- Before using beets, you'll need a configuration file. This YAML file tells beets where to store your music and how to organize it. While beets is highly configurable, you only need a few basic settings to get started. 1. **Open the config file:** .. code-block:: console beet config -e This creates the file (if needed) and opens it in your default editor. You can also find its location with ``beet config -p``. 2. **Add required settings:** In the config file, set the ``directory`` option to the path where you want beets to store your music files. Set the ``library`` option to the path where you want beets to store its database file. .. code-block:: yaml directory: ~/music library: ~/data/musiclibrary.db 3. **Choose your import style** (pick one): Beets offers flexible import strategies to match your workflow. Choose one of the following approaches and put one of the following in your config file: .. tab-set:: .. tab-item:: Copy Files (Default) This is the default configuration and assumes you want to start a new organized music folder (inside ``directory`` above). During import we will *copy* cleaned-up music into that empty folder. .. code-block:: yaml import: copy: yes # Copy files to new location .. tab-item:: Move Files Start with a new empty directory, but *move* new music in instead of copying it (saving disk space). .. code-block:: yaml import: move: yes # Move files to new location .. tab-item:: Use Existing Structure Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Make sure to point ``directory`` at the place where your music is currently stored. .. code-block:: yaml import: copy: no # Use files in place .. tab-item:: Read-Only Mode Keep everything exactly as-is; only track metadata in database. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.) .. code-block:: yaml import: copy: no # Use files in place write: no # Don't modify tags 4. **Add customization via plugins (optional):** Beets comes with many plugins that extend its functionality. You can enable plugins by adding a ``plugins`` section to your config file. We recommend adding at least one :ref:`Autotagger Plugin <autotagger_extensions>` to help with fetching metadata during import. For getting started, :doc:`MusicBrainz </plugins/musicbrainz>` is a good choice. .. code-block:: yaml plugins: - musicbrainz # Example plugin for fetching metadata - ... other plugins you want ... You can find a list of available plugins in the :doc:`plugins index </plugins/index>`. .. _yaml: https://yaml.org/ To validate that you've set up your configuration and it is valid YAML, you can type ``beet version`` to see a list of enabled plugins or ``beet config`` to get a complete listing of your current configuration. .. dropdown:: Minimal configuration Here's a sample configuration file that includes the settings mentioned above: .. code-block:: yaml directory: ~/music library: ~/data/musiclibrary.db import: move: yes # Move files to new location # copy: no # Use files in place # write: no # Don't modify tags plugins: - musicbrainz # Example plugin for fetching metadata # - ... other plugins you want ... You can copy and paste this into your config file and modify it as needed. .. admonition:: Ready for more? For a complete reference of all configuration options, see the :doc:`configuration reference </reference/config>`. Importing Your Music -------------------- Now you're ready to import your music into beets! .. important:: Importing can modify and move your music files. **Make sure you have a recent backup** before proceeding. Choose Your Import Method ~~~~~~~~~~~~~~~~~~~~~~~~~ There are two good ways to bring your *existing* library into beets database. .. tab-set:: .. tab-item:: Autotag (Recommended) This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go. .. code-block:: console beet import /a/chunk/of/my/library .. warning:: The point about speed bears repeating: using the autotagger on a large library can take a very long time, and it's an interactive process. So set aside a good chunk of time if you're going to go that route. We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging process, see :doc:`tagger`. .. tab-item:: Quick Import This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags. To use this method, run: .. code-block:: console beet import --noautotag /my/huge/mp3/library The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata. .. admonition:: More Import Options The ``beet import`` command has many options to customize its behavior. For a full list, type ``beet help import`` or see the :ref:`import command reference <import-cmd>`. Adding More Music Later ~~~~~~~~~~~~~~~~~~~~~~~ When you acquire new music, use the same ``beet import`` command to add it to your library: .. code-block:: console beet import ~/new_totally_not_ripped_album This will apply the same autotagging process to your new additions. For alternative import behaviors, consult the options mentioned above. Seeing Your Music ----------------- Once you've imported music into beets, you'll want to explore and query your library. Beets provides several commands for searching, browsing, and getting statistics about your collection. Basic Searching ~~~~~~~~~~~~~~~ The ``beet list`` command (shortened to ``beet ls``) lets you search your music library using :doc:`query string </reference/query>` similar to web searches: .. code-block:: console $ beet ls the magnetic fields The Magnetic Fields - Distortion - Three-Way The Magnetic Fields - Dist The Magnetic Fields - Distortion - Old Fools .. code-block:: console $ beet ls hissing gronlandic of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit .. code-block:: console $ beet ls bird The Knife - The Knife - Bird The Mae Shi - Terrorbird - Revelation Six By default, search terms match against :ref:`common attributes <keywordquery>` of songs, and multiple terms are combined with AND logic (a track must match *all* criteria). Searching Specific Fields ~~~~~~~~~~~~~~~~~~~~~~~~~ To narrow a search term to a particular metadata field, prefix the term with the field name followed by a colon. For example, ``album:bird`` searches for "bird" only in the "album" field of your songs. For more details, see :doc:`/reference/query/`. .. code-block:: console $ beet ls album:bird The Mae Shi - Terrorbird - Revelation Six This searches only the ``album`` field for the term ``bird``. Searching for Albums ~~~~~~~~~~~~~~~~~~~~ The ``beet list`` command also has an ``-a`` option, which searches for albums instead of songs: .. code-block:: console $ beet ls -a forever Bon Iver - For Emma, Forever Ago Freezepop - Freezepop Forever Custom Output Formatting ~~~~~~~~~~~~~~~~~~~~~~~~ There's also an ``-f`` option (for *format*) that lets you specify what gets displayed in the results of a search: .. code-block:: console $ beet ls -a forever -f "[$format] $album ($year) - $artist - $title" [MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume [AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme In the format string, field references like ``$format``, ``$year``, ``$album``, etc., are replaced with data from each result. .. dropdown:: Available fields for formatting To see all available fields you can use in custom formats, run: .. code-block:: console beet fields This will display a comprehensive list of metadata fields available for your music. Library Statistics ~~~~~~~~~~~~~~~~~~ Beets can also show you statistics about your music collection: .. code-block:: console $ beet stats Tracks: 13019 Total time: 4.9 weeks Total size: 71.1 GB Artists: 548 Albums: 1094 .. admonition:: Ready for more advanced queries? The ``beet list`` command has many additional options for sorting, limiting results, and more complex queries. For a complete reference, run: .. code-block:: console beet help list Or see the :ref:`list command reference <list-cmd>`. Keep Playing ------------ Congratulations! You've now mastered the basics of beets. But this is only the beginning, beets has many more powerful features to explore. Continue Your Learning Journey ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ *I was there to push people beyond what's expected of them.* .. grid:: 2 :gutter: 3 .. grid-item-card:: :octicon:`zap` Advanced Techniques :link: advanced :link-type: doc Explore sophisticated beets workflows including: - Advanced tagging strategies - Complex import scenarios - Custom metadata management - Workflow automation .. grid-item-card:: :octicon:`terminal` Command Reference :link: /reference/cli :link-type: doc Comprehensive guide to all beets commands: - Complete command syntax - All available options - Usage examples - **Important operations like deleting music** .. grid-item-card:: :octicon:`plug` Plugin Ecosystem :link: /plugins/index :link-type: doc Discover beets' true power through plugins: - Metadata fetching from multiple sources - Audio analysis and processing - Streaming service integration - Custom export formats .. grid-item-card:: :octicon:`question` Illustrated Walkthrough :link: https://beets.io/blog/walkthrough.html :link-type: url Visual, step-by-step guide covering: - Real-world import examples - Screenshots of interactive tagging - Common workflow patterns - Troubleshooting tips .. admonition:: Need Help? Remember you can always use ``beet help`` to see all available commands, or ``beet help [command]`` for detailed help on specific commands. Join the Community ~~~~~~~~~~~~~~~~~~ We'd love to hear about your experience with beets! .. grid:: 2 :gutter: 2 .. grid-item-card:: :octicon:`comment-discussion` Discussion Board :link: https://github.com/beetbox/beets/discussions :link-type: url - Ask questions - Share tips and tricks - Discuss feature ideas - Get help from other users .. grid-item-card:: :octicon:`git-pull-request` Developer Resources :link: /dev/index :link-type: doc - Contribute code - Report issues - Review pull requests - Join development discussions .. admonition:: Found a Bug? If you encounter any issues, please report them on our `GitHub Issues page <https://github.com/beetbox/beets/issues>`_. ================================================ FILE: docs/guides/tagger.rst ================================================ .. _using-the-auto-tagger: Using the Auto-Tagger ===================== Beets' automatic metadata correcter is sophisticated but complicated and cryptic. This is a guide to help you through its myriad inputs and options. An Apology and a Brief Interlude -------------------------------- I would like to sincerely apologize that the autotagger in beets is so fussy. It asks you a *lot* of complicated questions, insecurely asking that you verify nearly every assumption it makes. This means importing and correcting the tags for a large library can be an endless, tedious process. I'm sorry for this. Maybe it will help to think of it as a tradeoff. By carefully examining every album you own, you get to become more familiar with your library, its extent, its variation, and its quirks. People used to spend hours lovingly sorting and resorting their shelves of LPs. In the iTunes age, many of us toss our music into a heap and forget about it. This is great for some people. But there's value in intimate, complete familiarity with your collection. So instead of a chore, try thinking of correcting tags as quality time with your music collection. That's what I do. One practical piece of advice: because beets' importer runs in multiple threads, it queues up work in the background while it's waiting for you to respond. So if you find yourself waiting for beets for a few seconds between every question it asks you, try walking away from the computer for a while, making some tea, and coming back. Beets will have a chance to catch up with you and will ask you questions much more quickly. Back to the guide. Overview -------- Beets' tagger is invoked using the ``beet import`` command. Point it at a directory and it imports the files into your library, tagging them as it goes (unless you pass ``--noautotag``, of course). There are several assumptions beets currently makes about the music you import. In time, we'd like to remove all of these limitations. - Your music should be organized by album into directories. That is, the tagger assumes that each album is in a single directory. These directories can be arbitrarily deep (like ``music/2010/hiphop/seattle/freshespresso/glamour``), but any directory with music files in it is interpreted as a separate album. There are, however, a couple of exceptions to this rule: First, directories that look like separate parts of a *multi-disc album* are tagged together as a single release. If two adjacent albums have a common prefix, followed by "disc," "disk," or "CD" and then a number, they are tagged together. Second, if you have jumbled directories containing more than one album, you can ask beets to split them apart for you based on their metadata. Use either the ``--group-albums`` command-line flag or the *G* interactive option described below. - The music may have bad tags, but it's not completely untagged. This is because beets by default infers tags based on existing metadata. But this is not a hard and fast rule---there are a few ways to tag metadata-poor music: - You can use the *E* or *I* options described below to search in MusicBrainz for a specific album or song. - The :doc:`Acoustid plugin </plugins/chroma>` extends the autotagger to use acoustic fingerprinting to find information for arbitrary audio. Install that plugin if you're willing to spend a little more CPU power to get tags for unidentified albums. (But be aware that it does slow down the process.) - The :doc:`FromFilename plugin </plugins/fromfilename>` adds the ability to guess tags from the filenames. Use this plugin if your tracks have useful names (like "03 Call Me Maybe.mp3") but their tags don't reflect that. - Currently, MP3, AAC, FLAC, ALAC, Ogg Vorbis, Monkey's Audio, WavPack, Musepack, Windows Media, Opus, and AIFF files are supported. (Do you use some other format? Please `file a feature request`_!) .. _file a feature request: https://github.com/beetbox/beets/issues/new?template=feature-request.md Now that that's out of the way, let's tag some music. .. _import-options: Options ------- To import music, just say ``beet import MUSICDIR``. There are, of course, a few command-line options you should know: - ``beet import -A``: don't try to autotag anything; just import files (this goes much faster than with autotagging enabled) - ``beet import -W``: when autotagging, don't write new tags to the files themselves (just keep the new metadata in beets' database) - ``beet import -C``: don't copy imported files to your music directory; leave them where they are - ``beet import -m``: move imported files to your music directory (overrides the ``-c`` option) - ``beet import -l LOGFILE``: write a message to ``LOGFILE`` every time you skip an album or choose to take its tags "as-is" (see below) or the album is skipped as a duplicate; this lets you come back later and reexamine albums that weren't tagged successfully. Run ``beet import --from-logfile=LOGFILE`` rerun the importer on such paths from the logfile. - ``beet import -q``: quiet mode. Never prompt for input and, instead, conservatively skip any albums that need your opinion. The ``-ql`` combination is recommended. - ``beet import -t``: timid mode, which is sort of the opposite of "quiet." The importer will ask your permission for everything it does, confirming even very good matches with a prompt. - ``beet import -p``: automatically resume an interrupted import. The importer keeps track of imports that don't finish completely (either due to a crash or because you stop them halfway through) and, by default, prompts you to decide whether to resume them. The ``-p`` flag automatically says "yes" to this question. Relatedly, ``-P`` flag automatically says "no." - ``beet import -s``: run in *singleton* mode, tagging individual tracks instead of whole albums at a time. See the "as Tracks" choice below. This means you can use ``beet import -AC`` to quickly add a bunch of files to your library without doing anything to them. - ``beet import -g``: assume there are multiple albums contained in each directory. The tracks contained a directory are grouped by album artist and album name and you will be asked to import each of these groups separately. See the "Group albums" choice below. Similarity ---------- So you import an album into your beets library. It goes like this: :: $ beet imp witchinghour Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Here, beets gives you a preview of the album match it has found. It shows you which track titles will be changed if the match is applied. In this case, beets has found a match and thinks it's a good enough match to proceed without asking your permission. It has reported the *similarity* for the match it's found. Similarity is a measure of how well-matched beets thinks a tagging option is. 100% similarity means a perfect match 0% indicates a truly horrible match. In this case, beets has proceeded automatically because it found an option with very high similarity (98.4%). But, as you'll notice, if the similarity isn't quite so high, beets will ask you to confirm changes. This is because beets can't be very confident about more dissimilar matches, and you (as a human) are better at making the call than a computer. So it occasionally asks for help. Choices ------- When beets needs your input about a match, it says something like this: :: Tagging: Beirut - Lon Gisland (Similarity: 94.4%) * Scenic World (Second Version) -> Scenic World [A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, enter Id, or aBort? When beets asks you this question, it wants you to enter one of the capital letters: A, M, S, U, T, G, E, I or B. That is, you can choose one of the following: - *A*: Apply the suggested changes shown and move on. - *M*: Show more options. (See the Candidates section, below.) - *S*: Skip this album entirely and move on to the next one. - *U*: Import the album without changing any tags. This is a good option for albums that aren't in the MusicBrainz database, like your friend's operatic faux-goth solo record that's only on two CD-Rs in the universe. - *T*: Import the directory as *singleton* tracks, not as an album. Choose this if the tracks don't form a real release---you just have one or more loner tracks that aren't a full album. This will temporarily flip the tagger into *singleton* mode, which attempts to match each track individually. - *G*: Group tracks in this directory by *album artist* and *album* and import groups as albums. If the album artist for a track is not set then the artist is used to group that track. For each group importing proceeds as for directories. This is helpful if a directory contains multiple albums. - *E*: Enter an artist and album to use as a search in the database. Use this option if beets hasn't found any good options because the album is mistagged or untagged. - *I*: Enter a metadata backend ID to use as search in the database. Use this option to specify a backend entity (for example, a MusicBrainz release or recording) directly, by pasting its ID or the full URL. You can also specify several IDs by separating them by a space. - *B*: Cancel this import task altogether. No further albums will be tagged; beets shuts down immediately. The next time you attempt to import the same directory, though, beets will ask you if you want to resume tagging where you left off. Note that the option with ``[B]rackets`` is the default---so if you want to apply the changes, you can just hit return without entering anything. Candidates ---------- If you choose the M option, or if beets isn't very confident about any of the choices it found, it will present you with a list of choices (called candidates), like so: :: Finding tags for "Panther - Panther". Candidates: 1. Panther - Yourself (66.8%) 2. Tav Falco's Panther Burns - Return of the Blue Panther (30.4%) # selection (default 1), Skip, Use as-is, or Enter search, or aBort? Here, you have many of the same options as before, but you can also enter a number to choose one of the options that beets has found. Don't worry about guessing---beets will show you the proposed changes and ask you to confirm them, just like the earlier example. As the prompt suggests, you can just hit return to select the first candidate. .. _guide-duplicates: Duplicates ---------- If beets finds an album or item in your library that seems to be the same as the one you're importing, you may see a prompt like this: :: This album is already in the library! [S]kip new, Keep all, Remove old, Merge all? Beets wants to keep you safe from duplicates, which can be a real pain, so you have four choices in this situation. You can skip importing the new music, choosing to keep the stuff you already have in your library; you can keep both the old and the new music; you can remove the existing music and choose the new stuff; or you can merge all the new and old tracks into a single album. If you choose that "remove" option, any duplicates will be removed from your library database---and, if the corresponding files are located inside of your beets library directory, the files themselves will be deleted as well. If you choose "merge", beets will try re-importing the existing and new tracks as one bundle together. This is particularly helpful when you have an album that's missing some tracks and then want to import the remaining songs. The importer will ask you the same questions as it would if you were importing all tracks at once. If you choose to keep two identically-named albums, beets can avoid storing both in the same directory. See :ref:`aunique` for details. Fingerprinting -------------- You may have noticed by now that beets' autotagger works pretty well for most files, but can get confused when files don't have any metadata (or have wildly incorrect metadata). In this case, you need *acoustic fingerprinting*, a technology that identifies songs from the audio itself. With fingerprinting, beets can autotag files that have very bad or missing tags. The :doc:`"chroma" plugin </plugins/chroma>`, distributed with beets, uses the Chromaprint_ open-source fingerprinting technology, but it's disabled by default. That's because it's sort of tricky to install. See the :doc:`/plugins/chroma` page for a guide to getting it set up. Before you jump into acoustic fingerprinting with both feet, though, give beets a try without it. You may be surprised at how well metadata-based matching works. .. _chromaprint: https://acoustid.org/chromaprint Album Art, Lyrics, Genres and Such ---------------------------------- Aside from the basic stuff, beets can optionally fetch more specialized metadata. As a rule, plugins are responsible for getting information that doesn't come directly from the MusicBrainz database. This includes :doc:`album cover art </plugins/fetchart>`, :doc:`song lyrics </plugins/lyrics>`, and :doc:`musical genres </plugins/lastgenre>`. Check out the :doc:`list of plugins </plugins/index>` to pick and choose the data you want. Missing Albums? --------------- If you're having trouble tagging a particular album with beets, check to make sure the album is present in `the MusicBrainz database`_. You can search on their site to make sure it's cataloged there. If not, anyone can edit MusicBrainz---so consider adding the data yourself. .. _the musicbrainz database: https://musicbrainz.org/ If you receive a "No matching release found" message from the Auto-Tagger for an album you know is present in MusicBrainz, check that musicbrainz is in the plugin list. Until version v2.4.0_ the default metadata source for the Auto-Tagger, the :doc:`musicbrainz plugin </plugins/musicbrainz>`, had to be manually disabled. At present, if the plugin list is changed, musicbrainz needs to be added to the plugin list in order to continue contributing results to Auto-Tagger. If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. .. _file a bug report: https://github.com/beetbox/beets/issues .. _v2.4.0: https://github.com/beetbox/beets/releases/tag/v2.4.0 I Hope That Makes Sense ----------------------- If we haven't made the process clear, please post on `the discussion board`_ and we'll try to improve this guide. .. _the discussion board: https://github.com/beetbox/beets/discussions/ ================================================ FILE: docs/index.rst ================================================ beets: the music geek's media organizer ======================================= Welcome to the documentation for beets_, the media library management system for obsessive music geeks. If you're new to beets, begin with the :doc:`guides/main` guide. That guide walks you through installing beets, setting it up how you like it, and starting to build your music library. Then you can get a more detailed look at beets' features in the :doc:`/reference/cli/` and :doc:`/reference/config` references. You might also be interested in exploring the :doc:`plugins </plugins/index>`. If you still need help, you can drop by the ``#beets`` IRC channel on Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue tracker. Please let us know where you think this documentation can be improved. .. _beets: https://beets.io/ .. _file a bug: https://github.com/beetbox/beets/issues .. _the discussion board: https://github.com/beetbox/beets/discussions/ Contents -------- .. toctree:: :maxdepth: 2 guides/index reference/index plugins/index faq team contributing code_of_conduct dev/index .. toctree:: :maxdepth: 1 changelog ================================================ FILE: docs/modd.conf ================================================ **/*.rst { prep: make html } _build/html/** { daemon: devd -m _build/html } ================================================ FILE: docs/plugins/absubmit.rst ================================================ AcousticBrainz Submit Plugin ============================ The ``absubmit`` plugin lets you submit acoustic analysis results to an AcousticBrainz_ server. This plugin is now deprecated since the AcousicBrainz project has been shut down. As an alternative the beets-xtractor_ plugin can be used. Warning ------- The AcousticBrainz project has shut down. To use this plugin you must set the ``base_url`` configuration option to a server offering the AcousticBrainz API. Installation ------------ The ``absubmit`` plugin requires the streaming_extractor_music_ program to run. Its source can be found on GitHub_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz FAQ_). Then, install ``beets`` with ``absubmit`` extra pip install "beets[absubmit]" Lastly, enable the plugin in your configuration (see :ref:`using-plugins`). Submitting Data --------------- To run the analysis program and upload its results, type: :: beet absubmit [-f] [-d] [QUERY] By default, the command will only look for AcousticBrainz data when the tracks don't already have it; the ``-f`` or ``--force`` switch makes it refetch data even when it already exists. You can use the ``-d`` or ``--dry`` switch to check which files will be analyzed, before you start a longer period of processing. The plugin works on music with a MusicBrainz track ID attached. The plugin will also skip music that the analysis tool doesn't support. streaming_extractor_music_ currently supports files with the extensions ``mp3``, ``ogg``, ``oga``, ``flac``, ``mp4``, ``m4a``, ``m4r``, ``m4b``, ``m4p``, ``aac``, ``wma``, ``asf``, ``mpc``, ``wv``, ``spx``, ``tta``, ``3g2``, ``aif``, ``aiff`` and ``ape``. Configuration ------------- To configure the plugin, make a ``absubmit:`` section in your configuration file. The available options are: - **auto**: Analyze every file on import. Otherwise, you need to use the ``beet absubmit`` command explicitly. Default: ``no`` - **extractor**: The absolute path to the streaming_extractor_music_ binary. Default: search for the program in your ``$PATH`` - **force**: Analyze items and submit of AcousticBrainz data even for tracks that already have it. Default: ``no``. - **pretend**: Do not analyze and submit of AcousticBrainz data but print out the items which would be processed. Default: ``no``. - **base_url**: The base URL of the AcousticBrainz server. The plugin has no function if this option is not set. Default: None .. _acousticbrainz: https://acousticbrainz.org .. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor .. _faq: https://acousticbrainz.org/faq .. _github: https://github.com/MTG/essentia .. _pip: https://pip.pypa.io .. _requests: https://requests.readthedocs.io/en/latest/ .. _streaming_extractor_music: https://essentia.upf.edu/ ================================================ FILE: docs/plugins/acousticbrainz.rst ================================================ AcousticBrainz Plugin ===================== The ``acousticbrainz`` plugin gets acoustic-analysis information from the AcousticBrainz_ project. This plugin is now deprecated since the AcousicBrainz project has been shut down. As an alternative the beets-xtractor_ plugin can be used. .. _acousticbrainz: https://acousticbrainz.org/ .. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing: :: $ beet acousticbrainz [-f] [QUERY] By default, the command will only look for AcousticBrainz data when the tracks doesn't already have it; the ``-f`` or ``--force`` switch makes it re-download data even when it already exists. If you specify a query, only matching tracks will be processed; otherwise, the command processes every track in your library. For all tracks with a MusicBrainz recording ID, the plugin currently sets these fields: - ``average_loudness`` - ``bpm`` - ``chords_changes_rate`` - ``chords_key`` - ``chords_number_rate`` - ``chords_scale`` - ``danceable`` - ``gender`` - ``genre_rosamerica`` - ``initial_key`` (This is a built-in beets field, which can also be provided by :doc:`/plugins/keyfinder`.) - ``key_strength`` - ``mood_acoustic`` - ``mood_aggressive`` - ``mood_electronic`` - ``mood_happy`` - ``mood_party`` - ``mood_relaxed`` - ``mood_sad`` - ``moods_mirex`` - ``rhythm`` - ``timbre`` - ``tonal`` - ``voice_instrumental`` Warning ------- The AcousticBrainz project has shut down. To use this plugin you must set the ``base_url`` configuration option to a server offering the AcousticBrainz API. Automatic Tagging ----------------- To automatically tag files using AcousticBrainz data during import, just enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing new files, beets will query the AcousticBrainz API using MBID and set the appropriate metadata. Configuration ------------- To configure the plugin, make a ``acousticbrainz:`` section in your configuration file. The available options are: - **auto**: Enable AcousticBrainz during ``beet import``. Default: ``yes``. - **force**: Download AcousticBrainz data even for tracks that already have it. Default: ``no``. - **tags**: Which tags from the list above to set on your files. Default: [] (all). - **base_url**: The base URL of the AcousticBrainz server. The plugin has no function if this option is not set. Default: None ================================================ FILE: docs/plugins/advancedrewrite.rst ================================================ Advanced Rewrite Plugin ======================= The ``advancedrewrite`` plugin lets you easily substitute values in your templates and path formats, similarly to the :doc:`/plugins/rewrite`. It's recommended to read the documentation of that plugin first. The *advanced* rewrite plugin does not only support the simple rule format of the ``rewrite`` plugin, but also an advanced format: there, the plugin doesn't consider the value of the rewritten field, but instead checks if the given item matches a :doc:`query </reference/query>`. Only then, the field is replaced with the given value. It's also possible to replace multiple fields at once, and even supports multi-valued fields. To use advanced field rewriting, first enable the ``advancedrewrite`` plugin (see :ref:`using-plugins`). Then, make a ``advancedrewrite:`` section in your config file to contain your rewrite rules. In contrast to the normal ``rewrite`` plugin, you need to provide a list of replacement rule objects, which can have a different syntax depending on the rule complexity. The simple syntax is the same as the one of the rewrite plugin and allows to replace a single field: :: advancedrewrite: - artist ODD EYE CIRCLE: 이달의 소녀 오드아이써클 The advanced syntax consists of a query to match against, as well as a map of replacements to apply. For example, to credit all songs of ODD EYE CIRCLE before 2023 to their original group name, you can use the following rule: :: advancedrewrite: - match: "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022" replacements: artist: 이달의 소녀 오드아이써클 artist_sort: LOONA / ODD EYE CIRCLE Note how the sort name is also rewritten within the same rule. You can specify as many fields as you'd like in the replacements map. If you need to work with multi-valued fields, you can use the following syntax: :: advancedrewrite: - match: "artist:배유빈 feat. 김미현" replacements: artists: - 유빈 - 미미 As a convenience, the plugin applies patterns for the ``artist`` field to the ``albumartist`` field as well. (Otherwise, you would probably want to duplicate every rule for ``artist`` and ``albumartist``.) Make sure to properly quote your query strings if they contain spaces, otherwise they might not do what you expect, or even cause beets to crash. Take the following example: :: advancedrewrite: # BAD, DON'T DO THIS! - match: album:THE ALBUM replacements: artist: New artist On the first sight, this might look sane, and replace the artist of the album *THE ALBUM* with *New artist*. However, due to the space and missing quotes, this query will evaluate to ``album:THE`` and match ``ALBUM`` on any field, including ``artist``. As ``artist`` is the field being replaced, this query will result in infinite recursion and ultimately crash beets. Instead, you should use the following rule: :: advancedrewrite: # Note the quotes around the query string! - match: album:"THE ALBUM" replacements: artist: New artist A word of warning: This plugin theoretically only applies to templates and path formats; it initially does not modify files' metadata tags or the values tracked by beets' library database, but since it *rewrites all field lookups*, it modifies the file's metadata anyway. See comments in issue :bug:`2786`. As an alternative to this plugin the simpler but less powerful :doc:`/plugins/rewrite` can be used. If you don't want to modify the item's metadata and only replace values in file paths, you can check out the :doc:`/plugins/substitute`. ================================================ FILE: docs/plugins/albumtypes.rst ================================================ AlbumTypes Plugin ================= The ``albumtypes`` plugin adds the ability to format and output album types, such as "Album", "EP", "Single", etc. For the list of available album types, see the `MusicBrainz documentation`_. To use the ``albumtypes`` plugin, enable it in your configuration (see :ref:`using-plugins`). The plugin defines a new field ``$atypes``, which you can use in your path formats or elsewhere. .. _musicbrainz documentation: https://musicbrainz.org/doc/Release_Group/Type A bug introduced in beets 1.6.0 could have possibly imported broken data into the ``albumtypes`` library field. Please follow the instructions `described here <https://github.com/beetbox/beets/pull/4582#issuecomment-1445023493>`_ for a sanity check and potential fix. :bug:`4528` Configuration ------------- To configure the plugin, make a ``albumtypes:`` section in your configuration file. The available options are: - **types**: An ordered list of album type to format mappings. The order of the mappings determines their order in the output. If a mapping is missing or blank, it will not be in the output. - **ignore_va**: A list of types that should not be output for Various Artists albums. Useful for not adding redundant information - various artist albums are often compilations. - **bracket**: Defines the brackets to enclose each album type in the output. The default configuration looks like this: :: albumtypes: types: - ep: 'EP' - single: 'Single' - soundtrack: 'OST' - live: 'Live' - compilation: 'Anthology' - remix: 'Remix' ignore_va: compilation bracket: '[]' Examples -------- With path formats configured like: :: paths: default: $albumartist/[$year]$atypes $album/... albumtype:soundtrack: Various Artists/$album [$year]$atypes/... comp: Various Artists/$album [$year]$atypes/... The default plugin configuration generates paths that look like this, for example: :: Aphex Twin/[1993][EP][Remix] On Remixes Pink Floyd/[1995][Live] p·u·l·s·e Various Artists/20th Century Lullabies [1999] Various Artists/Ocean's Eleven [2001][OST] ================================================ FILE: docs/plugins/aura.rst ================================================ AURA Plugin =========== This plugin is a server implementation of the AURA_ specification using the Flask_ framework. AURA is still a work in progress and doesn't yet have a stable version, but this server should be kept up to date. You are advised to read the :ref:`aura-issues` section. .. _aura: https://auraspec.readthedocs.io/en/latest/ .. _flask: https://palletsprojects.com/projects/flask/ Install ------- To use the ``aura`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``aura`` extra pip install "beets[aura]" Usage ----- Use ``beet aura`` to start the AURA server. By default Flask's built-in server is used, which will give a warning about using it in a production environment. It is safe to ignore this warning if the server will have only a few users. Alternatively, you can use ``beet aura -d`` to start the server in `development mode <https://flask.palletsprojects.com/en/stable/server>`__, which will reload the server every time the AURA plugin file is changed. You can specify the hostname and port number used by the server in your :doc:`configuration file </reference/config>`. For more detail see the :ref:`configuration` section below. If you would prefer to use a different WSGI server, such as gunicorn or uWSGI, then see :ref:`aura-external-server`. AURA is designed to separate the client and server functionality. This plugin provides the server but not the client, so unless you like looking at JSON you will need a separate client. Currently the only client is `AURA Web Client`_. In order to use a local browser client with ``file:///`` see :ref:`aura-cors`. By default the API is served under http://127.0.0.1:8337/aura/. For example information about the track with an id of 3 can be obtained at http://127.0.0.1:8337/aura/tracks/3. **Note the absence of a trailing slash**: http://127.0.0.1:8337/aura/tracks/3/ returns a ``404 Not Found`` error. .. _aura web client: https://sr.ht/~callum/aura-web-client/ .. _configuration: Configuration ------------- To configure the plugin, make an ``aura:`` section in your configuration file. The available options are: - **host**: The server hostname. Set this to ``0.0.0.0`` to bind to all interfaces. Default: ``127.0.0.1``. - **port**: The server port. Default: ``8337``. - **cors**: A YAML list of origins to allow CORS requests from (see :ref:`aura-cors`, below). Default: disabled. - **cors_supports_credentials**: Allow authenticated requests when using CORS. Default: disabled. - **page_limit**: The number of items responses should be truncated to if the client does not specify. Default ``500``. .. _aura-cors: Cross-Origin Resource Sharing (CORS) ------------------------------------ `CORS <https://en.wikipedia.org/wiki/Cross-origin_resource_sharing>`__ allows browser clients to make requests to the AURA server. You should set the ``cors`` configuration option to a YAML list of allowed origins. For example: :: aura: cors: - http://www.example.com - https://aura.example.org In order to use the plugin with a local browser client accessed using ``file:///`` you must include ``'null'`` in the list of allowed origins (including quote marks): :: aura: cors: - 'null' Alternatively you use ``'*'`` to enable access from all origins. Note that there are security implications if you set the origin to ``'*'``, so please research this before using it. Note the use of quote marks when allowing all origins. If the server is behind a proxy that uses credentials, you might want to set the ``cors_supports_credentials`` configuration option to true to let in-browser clients log in. Note that this option has not been tested, so it may not work. .. _aura-external-server: Using an External WSGI Server ----------------------------- If you would like to use a different WSGI server (not Flask's built-in one), then you can! The ``beetsplug.aura`` module provides a WSGI callable called ``create_app()`` which can be used by many WSGI servers. For example to run the AURA server using gunicorn_ use ``gunicorn 'beetsplug.aura:create_app()'``, or for uWSGI_ use ``uwsgi --http :8337 --module 'beetsplug.aura:create_app()'``. Note that these commands just show how to use the AURA app and you would probably use something a bit different in a production environment. Read the relevant server's documentation to figure out what you need. .. _gunicorn: https://gunicorn.org .. _uwsgi: https://uwsgi-docs.readthedocs.io/en/latest/ Reverse Proxy Support --------------------- The plugin should work behind a reverse proxy without further configuration, however this has not been tested extensively. For details of what headers must be rewritten and a sample NGINX configuration see `Flask proxy setups`_. .. _flask proxy setups: https://flask.palletsprojects.com/en/stable/deploying/proxy_fix/ It is (reportedly) possible to run the application under a URL prefix (for example so you could have ``/foo/aura/server`` rather than ``/aura/server``), but you'll have to work it out for yourself :-) If using NGINX, do **not** add a trailing slash (``/``) to the URL where the application is running, otherwise you will get a 404. However if you are using Apache then you **should** add a trailing slash. .. _aura-issues: Issues ------ As of writing there are some differences between the specification and this implementation: - Compound filters are not specified in AURA, but this server interprets multiple ``filter`` parameters as AND. See `issue #19`_ for discussion. - The ``bitrate`` parameter used for content negotiation is not supported. Adding support for this is doable, but the way Flask handles acceptable MIME types means it's a lot easier not to bother with it. This means an error could be returned even if no transcoding was required. It is possible that some attributes required by AURA could be absent from the server's response if beets does not have a saved value for them. However, this has not happened so far. Beets fields (including flexible fields) that do not have an AURA equivalent are not provided in any resource's attributes section, however these fields may be used for filtering. The ``mimetype`` and ``framecount`` attributes for track resources are not supported. The first is due to beets storing the file type (e.g. ``MP3``), so it is hard to filter by MIME type. The second is because there is no corresponding beets field. Artists are defined by the ``artist`` field on beets Items, which means some albums have no ``artists`` relationship. Albums only have related artists when their beets ``albumartist`` field is the same as the ``artist`` field on at least one of it's constituent tracks. The only art tracked by beets is a single cover image, so only albums have related images at the moment. This could be expanded to looking in the same directory for other images, and relating tracks to their album's image. There are likely to be some performance issues, especially with larger libraries. Sorting, pagination and inclusion (most notably of images) are probably the main offenders. On a related note, the program attempts to import Pillow every time it constructs an image resource object, which is not good. The beets library is accessed using a so called private function (with a single leading underscore) ``beets.ui.__init__._open_library()``. This shouldn't cause any issues but it is probably not best practice. .. _issue #19: https://github.com/beetbox/aura/issues/19 ================================================ FILE: docs/plugins/autobpm.rst ================================================ AutoBPM Plugin ============== The ``autobpm`` plugin uses the Librosa_ library to calculate the BPM of a track from its audio data and store it in the ``bpm`` field of your database. It does so automatically when importing music or through the ``beet autobpm [QUERY]`` command. Install ------- To use the ``autobpm`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``autobpm`` extra .. code-block:: bash pip install "beets[autobpm]" Configuration ------------- To configure the plugin, make a ``autobpm:`` section in your configuration file. The available options are: - **auto**: Analyze every file on import. Otherwise, you need to use the ``beet autobpm`` command explicitly. Default: ``yes`` - **overwrite**: Calculate a BPM even for files that already have a ``bpm`` value. Default: ``no``. - **beat_track_kwargs**: Any extra keyword arguments that you would like to provide to librosa's beat_track_ function call, for example: .. code-block:: yaml autobpm: beat_track_kwargs: start_bpm: 160 .. _beat_track: https://librosa.org/doc/latest/generated/librosa.beat.beat_track.html .. _librosa: https://github.com/librosa/librosa/ ================================================ FILE: docs/plugins/badfiles.rst ================================================ Bad Files Plugin ================ The ``badfiles`` plugin adds a ``beet bad`` command to check for missing and corrupt files. Configuring ----------- First, enable the ``badfiles`` plugin (see :ref:`using-plugins`). The default configuration defines the following default checkers, which you may need to install yourself: - mp3val_ for MP3 files - FLAC_ command-line tools for FLAC files You can also add custom commands for a specific extension, like this: :: badfiles: check_on_import: yes commands: ogg: myoggchecker --opt1 --opt2 flac: flac --test --warnings-as-errors --silent Custom commands will be run once for each file of the specified type, with the path to the file as the last argument. Commands must return a status code greater than zero for a file to be considered corrupt. You can run the checkers when importing files by using the ``check_on_import`` option. When on, checkers will be run against every imported file and warnings and errors will be presented when selecting a tagging option. .. _flac: https://xiph.org/flac/ .. _mp3val: https://sourceforge.net/projects/mp3val/ Using ----- Type ``beet bad`` with a query according to beets' usual query syntax. For instance, this will run a check on all songs containing the word "wolf": :: beet bad wolf This one will run checks on a specific album: :: beet bad album_id:1234 Here is an example where the FLAC decoder signals a corrupt file: :: beet bad title::^$ /tank/Music/__/00.flac: command exited with status 1 00.flac: *** Got error code 2:FLAC__STREAM_DECODER_ERROR_STATUS_FRAME_CRC_MISMATCH 00.flac: ERROR while decoding data state = FLAC__STREAM_DECODER_READ_FRAME Note that the default ``mp3val`` checker is a bit verbose and can output a lot of "stream error" messages, even for files that play perfectly well. Generally, if more than one stream error happens, or if a stream error happens in the middle of a file, this is a bad sign. By default, only errors for the bad files will be shown. In order for the results for all of the checked files to be seen, including the uncorrupted ones, use the ``-v`` or ``--verbose`` option. ================================================ FILE: docs/plugins/bareasc.rst ================================================ Bare-ASCII Search Plugin ======================== The ``bareasc`` plugin provides a prefixed query that searches your library using simple ASCII character matching, with accented characters folded to their base ASCII character. This can be useful if you want to find a track with accented characters in the title or artist, particularly if you are not confident you have the accents correct. It is also not unknown for the accents to not be correct in the database entry or wrong in the CD information. First, enable the plugin named ``bareasc`` (see :ref:`using-plugins`). You'll then be able to use the ``#`` prefix to use bare-ASCII matching: :: $ beet ls '#dvorak' István Kertész - REQUIEM - Dvořàk: Requiem, op.89 - Confutatis maledictis Command ------- In addition to the query prefix, the plugin provides a utility ``bareasc`` command. This command is **exactly** the same as the ``beet list`` command except that the output is passed through the bare-ASCII transformation before being printed. This allows you to easily check what the library data looks like in bare ASCII, which can be useful if you are trying to work out why a query is not matching. Using the same example track as above: :: $ beet bareasc 'Dvořàk' Istvan Kertesz - REQUIEM - Dvorak: Requiem, op.89 - Confutatis maledictis Note: the ``bareasc`` command does *not* automatically use bare-ASCII queries. If you want a bare-ASCII query you still need to specify the ``#`` prefix. Notes ----- If the query string is all in lower case, the comparison ignores case as well as accents. The default ``bareasc`` prefix (``#``) is used as a comment character in some shells so may need to be protected (for example in quotes) when typed into the command line. The bare ASCII transliteration is quite simple. It may not give the expected output for all languages. For example, German u-umlaut ``ü`` is transformed into ASCII ``u``, not into ``ue``. The bare ASCII transformation also changes Unicode punctuation like double quotes, apostrophes and even some hyphens. It is often best to leave out punctuation in the queries. Note that the punctuation changes are often not even visible with normal terminal fonts. You can always use the ``bareasc`` command to print the transformed entries and use a command like ``diff`` to compare with the output from the ``list`` command. Configuration ------------- To configure the plugin, make a ``bareasc:`` section in your configuration file. The only available option is: - **prefix**: The character used to designate bare-ASCII queries. Default: ``#``, which may need to be escaped in some shells. Credits ------- The hard work in this plugin is done in Sean Burke's `Unidecode <https://pypi.org/project/Unidecode/>`__ library. Thanks are due to Sean and to all the people who created the Python version and the beets extensible query architecture. ================================================ FILE: docs/plugins/beatport.rst ================================================ Beatport Plugin =============== .. deprecated:: 2.8 Beatport retired the API this plugin relies on. See :bug:`3862` and :bug:`4477`. The ``beatport`` plugin adds support for querying the Beatport_ catalogue during the autotagging process. This can potentially be helpful for users whose collection includes a lot of diverse electronic music releases, for which both MusicBrainz and (to a lesser degree) Discogs_ show no matches. .. _discogs: https://discogs.com Installation ------------ To use the ``beatport`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``beatport`` extra .. code-block:: bash pip install "beets[beatport]" You will also need to register for a Beatport_ account. The first time you run the :ref:`import-cmd` command after enabling the plugin, it will ask you to authorize with Beatport by visiting the site in a browser. On the site you will be asked to enter your username and password to authorize beets to query the Beatport API. You will then be displayed with a single line of text that you should paste as a whole into your terminal. This will store the authentication data for subsequent runs and you will not be required to repeat the above steps. Matches from Beatport should now show up alongside matches from MusicBrainz and other sources. If you have a Beatport ID or a URL for a release or track you want to tag, you can just enter one of the two at the "enter Id" prompt in the importer. You can also search for an id like so: :: beet import path/to/music/library --search-id id Configuration ------------- This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. .. _beatport: https://www.beatport.com/ ================================================ FILE: docs/plugins/bpd.rst ================================================ BPD Plugin ========== BPD is a music player using music from a beets library. It runs as a daemon and implements the MPD protocol, so it's compatible with all the great MPD clients out there. I'm using Theremin_, gmpc_, Sonata_, and Ario_ successfully. .. _ario: https://sourceforge.net/projects/ario-player/ .. _gmpc: https://gmpc.fandom.com/wiki/Gnome_Music_Player_Client .. _sonata: https://www.nongnu.org/sonata/ .. _theremin: https://github.com/TheStalwart/Theremin Dependencies ------------ Before you can use BPD, you'll need the media library called GStreamer_ (along with its Python bindings) on your system. - On Mac OS X, you can use Homebrew_. Run ``brew install gstreamer gst-plugins-base pygobject3``. - On Linux, you need to install GStreamer 1.0 and the GObject bindings for python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``. You will also need the various GStreamer plugin packages to make everything work. See the :doc:`/plugins/chroma` documentation for more information on installing GStreamer plugins. Once you have system dependencies installed, install ``beets`` with ``bpd`` extra which installs Python bindings for ``GStreamer``: .. code-block:: console pip install "beets[bpd]" .. _gstreamer: https://gstreamer.freedesktop.org/ .. _homebrew: https://brew.sh Usage ----- To use the ``bpd`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, you can run BPD by invoking: :: $ beet bpd Fire up your favorite MPD client to start playing music. The MPD site has `a long list of available clients`_. Here are my favorites: .. _a long list of available clients: https://mpd.fandom.com/wiki/Clients - Linux: gmpc_, Sonata_ - Mac: Theremin_ - Windows: I don't know. Get in touch if you have a recommendation. - iPhone/iPod touch: Rigelian_ .. _rigelian: https://www.rigelian.net/ One nice thing about MPD's (and thus BPD's) client-server architecture is that the client can just as easily on a different computer from the server as it can be run locally. Control your music from your laptop (or phone!) while it plays on your headless server box. Rad! Configuration ------------- To configure the plugin, make a ``bpd:`` section in your configuration file. The available options are: - **host**: Default: Bind to all interfaces. - **port**: Default: 6600 - **password**: Default: No password. - **volume**: Initial volume, as a percentage. Default: 100 - **control_port**: Port for the internal control socket. Default: 6601 Here's an example: :: bpd: host: 127.0.0.1 port: 6600 password: seekrit volume: 100 Implementation Notes -------------------- In the real MPD, the user can browse a music directory as it appears on disk. In beets, we like to abstract away from the directory structure. Therefore, BPD creates a "virtual" directory structure (artist/album/track) to present to clients. This is static for now and cannot be reconfigured like the real on-disk directory structure can. (Note that an obvious solution to this is just string matching on items' destination, but this requires examining the entire library Python-side for every query.) BPD plays music using GStreamer's ``playbin`` player, which has a simple API but doesn't support many advanced playback features. Differences from the real MPD ----------------------------- BPD currently supports version 0.16 of `the MPD protocol`_, but several of the commands and features are "pretend" implementations or have slightly different behaviour to their MPD equivalents. BPD aims to look enough like MPD that it can interact with the ecosystem of clients, but doesn't try to be a fully-fledged MPD replacement in terms of its playback capabilities. .. _the mpd protocol: https://mpd.readthedocs.io/en/latest/protocol.html These are some of the known differences between BPD and MPD: - BPD doesn't currently support versioned playlists. Many clients, however, use plchanges instead of playlistinfo to get the current playlist, so plchanges contains a dummy implementation that just calls playlistinfo. - Stored playlists aren't supported (BPD understands the commands though). - The ``stats`` command always send zero for ``playtime``, which is supposed to indicate the amount of time the server has spent playing music. BPD doesn't currently keep track of this. - The ``update`` command regenerates the directory tree from the beets database synchronously, whereas MPD does this in the background. - Advanced playback features like cross-fade, ReplayGain and MixRamp are not supported due to BPD's simple audio player backend. - Advanced query syntax is not currently supported. - Clients can't use the ``tagtypes`` mask to hide fields. - BPD's ``random`` mode is not deterministic and doesn't support priorities. - Mounts and streams are not supported. BPD can only play files from disk. - Stickers are not supported (although this is basically a flexattr in beets nomenclature so this is feasible to add). - There is only a single password, and is enabled it grants access to all features rather than having permissions-based granularity. - Partitions and alternative outputs are not supported; BPD can only play one song at a time. - Client channels are not implemented. ================================================ FILE: docs/plugins/bpm.rst ================================================ BPM Plugin ========== This ``bpm`` plugin lets you to get the tempo (beats per minute) of a song by tapping out the beat on your keyboard. Usage ----- To use the ``bpm`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, play a song you want to measure in your favorite media player and type: :: beet bpm <song> You'll be prompted to press Enter three times to the rhythm. This typically allows to determine the BPM within 5% accuracy. The plugin works best if you wrap it in a script that gets the playing song. for instance, with ``mpc`` you can do something like: :: beet bpm $(mpc |head -1|tr -d "-") If :ref:`import.write <config-import-write>` is ``yes``, the song's tags are written to disk. Configuration ------------- To configure the plugin, make a ``bpm:`` section in your configuration file. The available options are: - **max_strokes**: The maximum number of strokes to accept when tapping out the BPM. Default: 3. - **overwrite**: Overwrite the track's existing BPM. Default: ``yes``. Credit ------ This plugin is inspired by a similar feature present in the Banshee media player. ================================================ FILE: docs/plugins/bpsync.rst ================================================ BPSync Plugin ============= .. deprecated:: 2.8 Depends on the deprecated :doc:`beatport` plugin. See :bug:`3862` and :bug:`4477`. This plugin provides the ``bpsync`` command, which lets you fetch metadata from Beatport for albums and tracks that already have Beatport IDs. This plugin works similarly to :doc:`/plugins/mbsync`. If you have downloaded music from Beatport, this can speed up the initial import if you just import "as-is" and then use ``bpsync`` to get up-to-date tags that are written to the files according to your beets configuration. Usage ----- Enable the ``bpsync`` plugin in your configuration (see :ref:`using-plugins`) and then run ``beet bpsync QUERY`` to fetch updated metadata for a part of your collection (or omit the query to run over your whole library). This plugin treats albums and singletons (non-album tracks) separately. It first processes all matching singletons and then proceeds on to full albums. The same query is used to search for both kinds of entities. The command has a few command-line options: - To preview the changes that would be made without applying them, use the ``-p`` (``--pretend``) flag. - By default, files will be moved (renamed) according to their metadata if they are inside your beets library directory. To disable this, use the ``-M`` (``--nomove``) command-line option. - If you have the ``import.write`` configuration option enabled, then this plugin will write new metadata to files' tags. To disable this, use the ``-W`` (``--nowrite``) option. ================================================ FILE: docs/plugins/bucket.rst ================================================ Bucket Plugin ============= The ``bucket`` plugin groups your files into buckets folders representing *ranges*. This kind of organization can classify your music by periods of time (e.g,. *1960s*, *1970s*, etc.), or divide overwhelmingly large folders into smaller subfolders by grouping albums or artists alphabetically (e.g. *A-F*, *G-M*, *N-Z*). To use the ``bucket`` plugin, first enable it in your configuration (see :ref:`using-plugins`). The plugin provides a :ref:`template function <template-functions>` called ``%bucket`` for use in path format expressions: :: paths: default: /%bucket{$year}/%bucket{$artist}/$albumartist-$album-$year Then, define your ranges in the ``bucket:`` section of the config file: :: bucket: bucket_alpha: ['A-F', 'G-M', 'N-Z'] bucket_year: ['1980s', '1990s', '2000s'] The ``bucket_year`` parameter is used for all substitutions occurring on the ``$year`` field, while ``bucket_alpha`` takes care of textual fields. The definition of a range is somewhat loose, and multiple formats are allowed: - For alpha ranges: the range is defined by the lowest and highest (ASCII-wise) alphanumeric characters in the string you provide. For example, ``ABCD``, ``A-D``, ``A->D``, and ``[AD]`` are all equivalent. - For year ranges: digits characters are extracted and the two extreme years define the range. For example, ``1975-77``, ``1975,76,77`` and ``1975-1977`` are equivalent. If no upper bound is given, the range is extended to current year (unless a later range is defined). For example, ``1975`` encompasses all years from 1975 until now. The ``%bucket`` template function guesses whether to use alpha- or year-style buckets depending on the text it receives. It can guess wrong if, for example, an artist or album happens to begin with four digits. Provide ``alpha`` as the second argument to the template to avoid this automatic detection: for example, use ``%bucket{$artist,alpha}``. Configuration ------------- To configure the plugin, make a ``bucket:`` section in your configuration file. The available options are: - **bucket_alpha**: Ranges to use for all substitutions occurring on textual fields. Default: none. - **bucket_alpha_regex**: A ``range: regex`` mapping (one per line) where ``range`` is one of the ``bucket_alpha`` ranges and ``value`` is a regex that overrides original range definition. Default: none. - **bucket_year**: Ranges to use for all substitutions occurring on the ``$year`` field. Default: none. - **extrapolate**: Enable this if you want to group your files into multiple year ranges without enumerating them all. This option will generate year bucket names by reproducing characteristics of declared buckets. Default: ``no`` Here's an example: :: bucket: bucket_year: ['2000-05'] extrapolate: true bucket_alpha: ['A - D', 'E - L', 'M - R', 'S - Z'] bucket_alpha_regex: 'A - D': ^[0-9a-dA-D…äÄ] This configuration creates five-year ranges for any input year. The ``A - D`` bucket now matches also all artists starting with ä or Ä and 0 to 9 and … (ellipsis). The other alpha buckets work as ranges. ================================================ FILE: docs/plugins/chroma.rst ================================================ Chromaprint/Acoustid Plugin =========================== Acoustic fingerprinting is a technique for identifying songs from the way they "sound" rather from their existing metadata. That means that beets' autotagger can theoretically use fingerprinting to tag files that don't have any ID3 information at all (or have completely incorrect data). This plugin uses an open-source fingerprinting technology called Chromaprint_ and its associated Web service, called Acoustid_. .. _acoustid: https://acoustid.org/ .. _chromaprint: https://acoustid.org/chromaprint Turning on fingerprinting can increase the accuracy of the autotagger---especially on files with very poor metadata---but it comes at a cost. First, it can be trickier to set up than beets itself (you need to set up the native fingerprinting library, whereas all of the beets core is written in pure Python). Also, fingerprinting takes significantly more CPU and memory than ordinary tagging---which means that imports will go substantially slower. If you're willing to pay the performance cost for fingerprinting, read on! Installing Dependencies ----------------------- To get fingerprinting working, you'll need to install three things: 1. pyacoustid_ Python library (version 0.6 or later). You can install it by installing ``beets`` with ``chroma`` extra .. code-block:: bash pip install "beets[chroma]" 2. the Chromaprint_ library_ or |command-line-tool|_ 3. an |audio-decoder|_ .. |command-line-tool| replace:: command line tool .. |audio-decoder| replace:: audio decoder .. _command-line-tool: Installing the Binary Command-Line Tool ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The simplest way to get up and running, especially on Windows, is to download_ the appropriate Chromaprint binary package and place the ``fpcalc`` (or ``fpcalc.exe``) on your shell search path. On Windows, this means something like ``C:\\Program Files``. On OS X or Linux, put the executable somewhere like ``/usr/local/bin``. .. _download: https://acoustid.org/chromaprint .. _library: Installing the Library ~~~~~~~~~~~~~~~~~~~~~~ On OS X and Linux, you can also use a library installed by your package manager, which has some advantages (automatic upgrades, etc.). The Chromaprint site has links to packages for major Linux distributions. If you use Homebrew_ on Mac OS X, you can install the library with ``brew install chromaprint``. .. _audio-decoder: .. _homebrew: https://brew.sh/ Audio Decoder ~~~~~~~~~~~~~ You will also need a mechanism for decoding audio files supported by the audioread_ library: - OS X has a number of decoders already built into Core Audio, so there's no need to install anything. - On Linux, you can install GStreamer_ with PyGObject_, FFmpeg_, or MAD_ with pymad_. How you install these will depend on your distribution. For example, on Ubuntu, run ``apt-get install gstreamer1.0 python-gi``. On Arch Linux, you want ``pacman -S gstreamer python2-gobject``. If you use GStreamer, be sure to install its codec plugins also (``gst-plugins-good``, etc.). Note that if you install beets in a virtualenv, you'll need it to have ``--system-site-packages`` enabled for Python to see the GStreamer bindings. - On Windows, builds are provided by GStreamer_ .. _audioread: https://github.com/beetbox/audioread .. _core audio: https://developer.apple.com/technologies/mac/audio-and-video.html .. _ffmpeg: https://ffmpeg.org/ .. _gstreamer: https://gstreamer.freedesktop.org/ .. _mad: https://www.underbit.com/products/mad/ .. _pyacoustid: https://github.com/beetbox/pyacoustid .. _pygobject: https://wiki.gnome.org/Projects/PyGObject .. _pymad: https://spacepants.org/src/pymad/ To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the standard set of Gstreamer plugins. For example, on Ubuntu, install the packages ``gstreamer1.0-plugins-good``, ``gstreamer1.0-plugins-bad``, and ``gstreamer1.0-plugins-ugly``. Usage ----- Once you have all the dependencies sorted out, enable the ``chroma`` plugin in your configuration (see :ref:`using-plugins`) to benefit from fingerprinting the next time you run ``beet import``. (The plugin doesn't produce any obvious output by default. If you want to confirm that it's enabled, you can try running in verbose mode once with ``beet -v import``.) You can also use the ``beet fingerprint`` command to generate fingerprints for items already in your library. (Provide a query to fingerprint a subset of your library.) The generated fingerprints will be stored in the library database. If you have the ``import.write`` config option enabled, they will also be written to files' metadata. .. _submitfp: Configuration ------------- There is one configuration option in the ``chroma:`` section, ``auto``, which controls whether to fingerprint files during the import process. To disable fingerprint-based autotagging, set it to ``no``, like so: :: chroma: auto: no Submitting Fingerprints ----------------------- You can help expand the Acoustid_ database by submitting fingerprints for the music in your collection. To do this, first `get an API key`_ from the Acoustid service. Just use an OpenID or MusicBrainz account to log in and you'll get a short token string. Then, add the key to your ``config.yaml`` as the value ``apikey`` in a section called ``acoustid`` like so: :: acoustid: apikey: AbCd1234 Then, run ``beet submit``. (You can also provide a query to submit a subset of your library.) The command will use stored fingerprints if they're available; otherwise it will fingerprint each file before submitting it. .. _get an api key: https://acoustid.org/api-key ================================================ FILE: docs/plugins/convert.rst ================================================ Convert Plugin ============== The ``convert`` plugin lets you convert parts of your collection to a directory of your choice, transcoding audio and embedding album art along the way. It can transcode to and from any format using a configurable command line. Optionally an m3u playlist file containing all the converted files can be saved to the destination path. Installation ------------ To use the ``convert`` plugin, first enable it in your configuration (see :ref:`using-plugins`). By default, the plugin depends on FFmpeg_ to transcode the audio, so you might want to install it. .. _ffmpeg: https://ffmpeg.org Usage ----- To convert a part of your collection, run ``beet convert QUERY``. The command will transcode all the files matching the query to the destination directory given by the ``-d`` (``--dest``) option or the ``dest`` configuration. The path layout mirrors that of your library, but it may be customized through the ``paths`` configuration. Files that have been previously converted---and thus already exist in the destination directory---will be skipped. The plugin uses a command-line program to transcode the audio. With the ``-f`` (``--format``) option you can choose the transcoding command and customize the available commands :ref:`through the configuration <convert-format-config>`. Unless the ``-y`` (``--yes``) flag is set, the command will list all the items to be converted and ask for your confirmation. The ``-a`` (or ``--album``) option causes the command to match albums instead of tracks. By default, the command places converted files into the destination directory and leaves your library pristine. To instead back up your original files into the destination directory and keep converted files in your library, use the ``-k`` (or ``--keep-new``) option. To test your configuration without taking any actions, use the ``--pretend`` flag. The plugin will print out the commands it will run instead of executing them. By default, files that do not need to be transcoded will be copied to their destination. Passing the ``-l`` (``--link``) flag creates symbolic links instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art embedding is disabled for files that are linked. Refer to the ``link`` and ``hardlink`` options below. The ``-F`` (or ``--force``) option forces transcoding even when safety options such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would normally cause a file to be copied or skipped instead. This can be combined with ``--format`` to explicitly transcode lossy inputs to a chosen target format. The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8 playlist file in the destination folder given by the ``-d`` (``--dest``) option or the ``dest`` configuration. The path to the playlist file can either be absolute or relative to the ``dest`` directory. The contents will always be relative paths to media files, which tries to ensure compatibility when read from external drives or on computers other than the one used for the conversion. There is one caveat though: A list generated on Unix/macOS can't be read on Windows and vice versa. Depending on the beets user's settings a generated playlist potentially could contain unicode characters. This is supported, playlists are written in `M3U8 format`_. Configuration ------------- To configure the plugin, make a ``convert:`` section in your configuration file. The available options are: - **auto**: Import transcoded versions of your files automatically during imports. With this option enabled, the importer will transcode all (in the default configuration) non-MP3 files over the maximum bitrate before adding them to your library. Default: ``no``. - **auto_keep**: Convert your files automatically on import to **dest** but import the non transcoded version. It uses the default format you have defined in your config file. Default: ``no``. .. note:: You probably want to use only one of the ``auto`` and ``auto_keep`` options, not both. Enabling both will convert your files twice on import, which you probably don't want. - **tmpdir**: The directory where temporary files will be stored during import. Default: none (system default), - **copy_album_art**: Copy album art when copying or transcoding albums matched using the ``-a`` option. Default: ``no``. - **album_art_maxwidth**: Downscale album art if it's too big. The resize operation reduces image width to at most ``maxwidth`` pixels while preserving the aspect ratio. The specified image size will apply to both embedded album art and external image files. - **dest**: The directory where the files will be converted (or copied) to. Default: none. - **embed**: Embed album art in converted items. Default: ``yes``. - **id3v23**: Can be used to override the global ``id3v23`` option. Default: ``inherit``. - **write_metadata**: Can be used to disable writing metadata to converted files. Default: ``true``. - **max_bitrate**: By default, the plugin does not transcode files that are already in the destination format. This option instead also transcodes files with high bitrates, even if they are already in the same format as the output. Note that this does not guarantee that all converted files will have a lower bitrate---that depends on the encoder and its configuration. Default: none. This option will be overridden by the ``--force`` flag - **no_convert**: Does not transcode items matching the query string provided (see :doc:`/reference/query`). For example, to not convert AAC or WMA formats, you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only want to transcode WMA format, you can use a negative query, e.g., ``^path::\.(wma)$``, to not convert any other format except WMA. This option will be overridden by the ``--force`` flag - **never_convert_lossy_files**: Cross-conversions between lossy codecs---such as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality even further. If set to ``yes``, lossy files are always copied. Default: ``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied or linked as-is. To explicitly transcode lossy files in spite of this, use the ``--force`` option with the ``convert`` command (optionally together with ``--format`` to choose a target format) - **paths**: The directory structure and naming scheme for the converted files. Uses the same format as the top-level ``paths`` section (see :ref:`path-format-config`). Default: Reuse your top-level path format settings. - **quiet**: Prevent the plugin from announcing every file it processes. Default: ``false``. - **threads**: The number of threads to use for parallel encoding. By default, the plugin will detect the number of processors available and use them all. - **link**: By default, files that do not need to be transcoded will be copied to their destination. This option creates symbolic links instead. Note that options such as ``embed`` that modify the output files after the transcoding step will cause the original files to be modified as well if ``link`` is enabled. For this reason, album-art embedding is disabled for files that are linked. Default: ``false``. - **hardlink**: This options works similar to ``link``, but it creates hard links instead of symlinks. This option overrides ``link``. Only works when converting to a directory on the same filesystem as the library. Default: ``false``. - **delete_originals**: Transcoded files will be copied or moved to their destination, depending on the import configuration. By default, the original files are not modified by the plugin. This option deletes the original files after the transcoding step has completed. Default: ``false``. - **playlist**: The name of a playlist file that should be written on each run of the plugin. A relative file path (e.g ``playlists/mylist.m3u8``) is allowed as well. The final destination of the playlist file will always be relative to the destination path (``dest``, ``--dest``, ``-d``). This configuration is overridden by the ``-m`` (``--playlist``) command line option. Default: none. You can also configure the format to use for transcoding (see the next section): - **format**: The name of the format to transcode to when none is specified on the command line. Default: ``mp3``. - **formats**: A set of formats and associated command lines for transcoding each. .. _convert-format-config: Configuring the transcoding command ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can customize the transcoding command through the ``formats`` map and select a command with the ``--format`` command-line option or the ``format`` configuration. :: convert: format: speex formats: speex: command: ffmpeg -i $source -y -acodec speex $dest extension: spx wav: ffmpeg -i $source -y -acodec pcm_s16le $dest In this example ``beet convert`` will use the *speex* command by default. To convert the audio to ``wav``, run ``beet convert -f wav``. This will also use the format key (``wav``) as the file extension. Each entry in the ``formats`` map consists of a key (the name of the format) as well as the command and optionally the file extension. ``extension`` is the filename extension to be used for newly transcoded files. If only the command is given as a string or the extension is not provided, the file extension defaults to the format's name. ``command`` is the command to use to transcode audio. The tokens ``$source`` and ``$dest`` in the command are replaced with the paths to the existing and new file. The plugin in comes with default commands for the most common audio formats: ``mp3``, ``alac``, ``flac``, ``aac``, ``opus``, ``ogg``, ``wma``. For details have a look at the output of ``beet config -d``. For a one-command-fits-all solution use the ``convert.command`` and ``convert.extension`` options. If these are set, the formats are ignored and the given command is used for all conversions. :: convert: command: ffmpeg -i $source -y -vn -aq 2 $dest extension: mp3 Gapless MP3 encoding ~~~~~~~~~~~~~~~~~~~~ While FFmpeg cannot produce "gapless_" MP3s by itself, you can create them by using LAME_ directly. Use a shell script like this to pipe the output of FFmpeg into the LAME tool: :: #!/bin/sh ffmpeg -i "$1" -f wav - | lame -V 2 --noreplaygain - "$2" Then configure the ``convert`` plugin to use the script: :: convert: command: /path/to/script.sh $source $dest extension: mp3 This strategy configures FFmpeg to produce a WAV file with an accurate length header for LAME to use. Using ``--noreplaygain`` disables gain analysis; you can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME documentation_ and the `HydrogenAudio wiki`_ for other LAME configuration options and a thorough discussion of MP3 encoding. .. _documentation: https://sourceforge.net/projects/lame/ .. _gapless: https://wiki.hydrogenaudio.org/index.php?title=Gapless_playback .. _hydrogenaudio wiki: https://wiki.hydrogenaudio.org/index.php?title=LAME .. _lame: https://sourceforge.net/projects/lame/ .. _m3u8 format: https://en.wikipedia.org/wiki/M3U#M3U8 ================================================ FILE: docs/plugins/deezer.rst ================================================ Deezer Plugin ============= The ``deezer`` plugin provides metadata matches for the importer using the Deezer_ Album_ and Track_ APIs. .. _album: https://developers.deezer.com/api/album .. _deezer: https://www.deezer.com/en/ .. _track: https://developers.deezer.com/api/track Basic Usage ----------- First, enable the ``deezer`` plugin (see :ref:`using-plugins`). You can enter the URL for an album or song on Deezer at the ``enter Id`` prompt during import: :: Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i Enter release ID: https://www.deezer.com/en/album/572261 Configuration ------------- This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. Default ~~~~~~~ .. code-block:: yaml deezer: search_query_ascii: no data_source_mismatch_penalty: 0.5 search_limit: 5 .. conf:: search_query_ascii :default: no If enabled, the search query will be converted to ASCII before being sent to Deezer. Converting searches to ASCII can enhance search results in some cases, but in general, it is not recommended. For instance, ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice ``×!=x``). .. include:: ./shared_metadata_source_config.rst Commands -------- The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a global indicator of a song's popularity on Deezer that is updated daily based on streams. The higher the ``rank``, the more popular the track is. ================================================ FILE: docs/plugins/discogs.rst ================================================ Discogs Plugin ============== The ``discogs`` plugin extends the autotagger's search capabilities to include matches from the Discogs_ database. Files can be imported as albums or as singletons. Since Discogs_ matches are always based on Discogs_ releases, the album tag is written even to singletons. This enhances the importers results when reimporting as (full or partial) albums later on. .. _discogs: https://discogs.com Installation ------------ To use the ``discogs`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``discogs`` extra .. code-block:: bash pip install "beets[discogs]" You will also need to register for a Discogs_ account, and provide authentication credentials via a personal access token or an OAuth2 authorization. Matches from Discogs will now show up during import alongside matches from MusicBrainz. The search terms sent to the Discogs API are based on the artist and album tags of your tracks. If those are empty no query will be issued. If you have a Discogs ID for an album you want to tag, you can also enter it at the "enter Id" prompt in the importer. OAuth Authorization ~~~~~~~~~~~~~~~~~~~ The first time you run the :ref:`import-cmd` command after enabling the plugin, it will ask you to authorize with Discogs by visiting the site in a browser. Subsequent runs will not require re-authorization. Authentication via Personal Access Token ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ As an alternative to OAuth, you can get a token from Discogs and add it to your configuration. To get a personal access token (called a "user token" in the python3-discogs-client_ documentation): 1. login to Discogs_; 2. visit the `Developer settings page <https://www.discogs.com/settings/developers>`_; 3. press the *Generate new token* button; 4. copy the generated token; 5. place it in your configuration in the ``discogs`` section as the ``user_token`` option: .. code-block:: yaml discogs: user_token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" Configuration ------------- This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. Default ~~~~~~~ .. code-block:: yaml discogs: apikey: REDACTED apisecret: REDACTED tokenfile: discogs_token.json user_token: index_tracks: no append_style_genre: no separator: ', ' strip_disambiguation: yes featured_string: Feat. extra_tags: [] anv: artist_credit: yes artist: no album_artist: no data_source_mismatch_penalty: 0.5 search_limit: 5 .. conf:: index_tracks :default: no Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions between distinct works on the same release or within works. When enabled, beets will incorporate the names of the divisions containing each track into the imported track's title. For example, importing `divisions album`_ would result in track names like: .. code-block:: text Messiah, Part I: No.1: Sinfony Messiah, Part II: No.22: Chorus- Behold The Lamb Of God Athalia, Act I, Scene I: Sinfonia whereas with ``index_tracks`` disabled you'd get: .. code-block:: text No.1: Sinfony No.22: Chorus- Behold The Lamb Of God Sinfonia This option is useful when importing classical music. .. conf:: append_style_genre :default: no Appends the Discogs style (if found) to the ``genres`` tag. This can be useful if you want more granular genres to categorize your music. For example, a release in Discogs might have a genre of "Electronic" and a style of "Techno": enabling this setting would append "Techno" to the ``genres`` list. .. conf:: separator :default: ", " How to join multiple style values from Discogs into a string. .. versionchanged:: 2.7.0 This option now only applies to the ``style`` field as beets now only handles lists of ``genres``. .. conf:: strip_disambiguation :default: yes Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with the same name. If you'd like to use the Discogs disambiguation in your tags, you can disable this option. .. conf:: featured_string :default: Feat. Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``. .. conf:: extra_tags :default: [] By default, beets will use only the artist and album to query Discogs. Additional tags to be queried can be supplied with the ``extra_tags`` setting. This setting should improve the autotagger results if the metadata with the given tags match the metadata returned by Discogs. Tags supported by this setting: * ``barcode`` * ``catalognum`` * ``country`` * ``label`` * ``media`` * ``year`` Example: .. code-block:: yaml discogs: extra_tags: [barcode, catalognum, country, label, media, year] .. conf:: anv This configuration option is dedicated to handling Artist Name Variations (ANVs). Sometimes a release credits artists differently compared to the majority of their work. For example, "Basement Jaxx" may be credited as "Tha Jaxx" or "The Basement Jaxx". You can select any combination of these config options to control where beets writes and stores the variation credit. The default, shown below, writes variations to the artist_credit field. .. code-block:: yaml discogs: anv: artist_credit: yes artist: no album_artist: no .. include:: ./shared_metadata_source_config.rst .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings .. _divisions album: https://www.discogs.com/release/2026070-Handel-Sutherland-Kirkby-Kwella-Nelson-Watkinson-Bowman-Rolfe-Johnson-Elliott-Partridge-Thomas-The-A Troubleshooting --------------- Several issues have been encountered with the Discogs API. If you have one, please start by searching for `a similar issue on the repo <https://github.com/beetbox/beets/issues?utf8=%E2%9C%93&q=is%3Aissue+discogs>`_. Here are two things you can try: - Try deleting the token file (``~/.config/beets/discogs_token.json`` by default) to force re-authorization. - Make sure that your system clock is accurate. The Discogs servers can reject your request if your clock is too out of sync. Matching tracks by Discogs ID is not yet supported. The ``--group-albums`` option in album import mode provides an alternative to singleton mode for autotagging tracks that are not in album-related folders. .. _python3-discogs-client: https://github.com/joalla/discogs_client ================================================ FILE: docs/plugins/duplicates.rst ================================================ Duplicates Plugin ================= This plugin adds a new command, ``duplicates`` or ``dup``, which finds and lists duplicate tracks or albums in your collection. Usage ----- To use the ``duplicates`` plugin, first enable it in your configuration (see :ref:`using-plugins`). By default, the ``beet duplicates`` command lists the names of tracks in your library that are duplicates. It assumes that Musicbrainz track and album ids are unique to each track or album. That is, it lists every track or album with an ID that has been seen before in the library. You can customize the output format, count the number of duplicate tracks or albums, and list all tracks that have duplicates or just the duplicates themselves via command-line switches :: -h, --help show this help message and exit -f FMT, --format=FMT print with custom format -a, --album show duplicate albums instead of tracks -c, --count count duplicate tracks or albums -C PROG, --checksum=PROG report duplicates based on arbitrary command -d, --delete delete items from library and disk -F, --full show all versions of duplicate tracks or albums -s, --strict report duplicates only if all attributes are set -k, --key report duplicates based on keys (can be used multiple times) -M, --merge merge duplicate items -m DEST, --move=DEST move items to dest -o DEST, --copy=DEST copy items to dest -p, --path print paths for matched items or albums -t TAG, --tag=TAG tag matched items with 'k=v' attribute -r, --remove remove items from library Configuration ------------- To configure the plugin, make a ``duplicates:`` section in your configuration file. The available options mirror the command-line options: - **album**: List duplicate albums instead of tracks. Default: ``no``. - **checksum**: Use an arbitrary command to compute a checksum of items. This overrides the ``keys`` option the first time it is run; however, because it caches the resulting checksum as ``flexattrs`` in the database, you can use ``--key=name_of_the_checksumming_program --key=any_other_keys`` (or set the ``keys`` configuration option) the second time around. Default: ``ffmpeg -i {file} -f crc -``. - **copy**: A destination base directory into which to copy matched items. Default: none (disabled). - **count**: Print a count of duplicate tracks or albums in the format ``$albumartist - $album - $title: $count`` (for tracks) or ``$albumartist - $album: $count`` (for albums). Default: ``no``. - **delete**: Remove matched items from the library and from the disk. Default: ``no`` - **format**: A specific format with which to print every track or album. This uses the same template syntax as beets' :doc:`path formats </reference/pathformat>`. The usage is inspired by, and therefore similar to, the :ref:`list <list-cmd>` command. Default: :ref:`format_item` - **full**: List every track or album that has duplicates, not just the duplicates themselves. Default: ``no`` - **keys**: Define in which track or album fields duplicates are to be searched. By default, the plugin uses the musicbrainz track and album IDs for this purpose. Using the ``keys`` option (as a YAML list in the configuration file, or as space-delimited strings in the command-line), you can extend this behavior to consider other attributes. Default: ``[mb_trackid, mb_albumid]`` - **merge**: Merge duplicate items by consolidating tracks and-or metadata where possible. - **move**: A destination base directory into which it will move matched items. Default: none (disabled). - **path**: Output the path instead of metadata when listing duplicates. Default: ``no``. - **strict**: Do not report duplicate matches if some of the attributes are not defined (ie. null or empty). Default: ``no`` - **tag**: A ``key=value`` pair. The plugin will add a new ``key`` attribute with ``value`` value as a flexattr to the database for duplicate items. Default: ``no``. - **tiebreak**: Dictionary of lists of attributes keyed by ``items`` or ``albums`` to use when choosing duplicates. By default, the tie-breaking procedure favors the most complete metadata attribute set. If you would like to consider the lower bitrates as duplicates, for example, set ``tiebreak: items: [bitrate]``. Default: ``{}``. - **remove**: Remove matched items from the library, but not from the disk. Default: ``no``. Examples -------- List all duplicate tracks in your collection: :: beet duplicates List all duplicate tracks from 2008: :: beet duplicates year:2008 Print out a unicode histogram of duplicate track years using spark_: :: beet duplicates -f '$year' | spark ▆▁▆█▄▇▇▄▇▇▁█▇▆▇▂▄█▁██▂█▁▁██▁█▂▇▆▂▇█▇▇█▆▆▇█▇█▇▆██▂▇ Print out a listing of all albums with duplicate tracks, and respective counts: :: beet duplicates -ac The same as the above but include the original album, and show the path: :: beet duplicates -acf '$path' Get tracks with the same title, artist, and album: :: beet duplicates -k title -k albumartist -k album Compute Adler CRC32 or MD5 checksums, storing them as flexattrs, and report back duplicates based on those values: :: beet dup -C 'ffmpeg -i {file} -f crc -' beet dup -C 'md5sum {file}' Copy highly danceable items to ``party`` directory: :: beet dup --copy /tmp/party Move likely duplicates to ``trash`` directory: :: beet dup --move ${HOME}/.Trash Delete items (careful!), if they're Nickelback: :: beet duplicates --delete -k albumartist -k albumartist:nickelback Tag duplicate items with some flag: :: beet duplicates --tag dup=1 Ignore items with undefined keys: :: beet duplicates --strict Merge and delete duplicate albums with different missing tracks: :: beet duplicates --album --merge --delete .. _spark: https://github.com/holman/spark ================================================ FILE: docs/plugins/edit.rst ================================================ Edit Plugin =========== The ``edit`` plugin lets you modify music metadata using your favorite text editor. Enable the ``edit`` plugin in your configuration (see :ref:`using-plugins`) and then type: :: beet edit QUERY Your text editor (i.e., the command in your ``$VISUAL`` or ``$EDITOR`` environment variable) will open with a list of tracks to edit. Make your changes and exit your text editor to apply them to your music. Command-Line Options -------------------- The ``edit`` command has these command-line options: - ``-a`` or ``--album``: Edit albums instead of individual items. - ``-f FIELD`` or ``--field FIELD``: Specify an additional field to edit (in addition to the defaults set in the configuration). - ``--all``: Edit *all* available fields. Interactive Usage ----------------- The ``edit`` plugin can also be invoked during an import session. If enabled, it adds two new options to the user prompt: :: [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort, eDit, edit Candidates? - ``eDit``: use this option for using the original items' metadata as the starting point for your edits. - ``edit Candidates``: use this option for using a candidate's metadata as the starting point for your edits. Please note that currently the interactive usage of the plugin will only allow you to change the item-level fields. In case you need to edit the album-level fields, the recommended approach is to invoke the plugin via the command line in album mode (``beet edit -a QUERY``) after the import. Also, please be aware that the ``edit Candidates`` choice can only be used with the matches found during the initial search (and currently not supporting the candidates found via the ``Enter search`` or ``enter Id`` choices). You might find the ``--search-id SEARCH_ID`` :ref:`import-cmd` option useful for those cases where you already have a specific candidate ID that you want to edit. Configuration ------------- To configure the plugin, make an ``edit:`` section in your configuration file. The available options are: - **itemfields**: A space-separated list of item fields to include in the editor by default. Default: ``track title artist album`` - **albumfields**: The same when editing albums (with the ``-a`` option). Default: ``album albumartist`` ================================================ FILE: docs/plugins/embedart.rst ================================================ EmbedArt Plugin =============== Typically, beets stores album art in a "file on the side": along with each album, there is a file (named "cover.jpg" by default) that stores the album art. You might want to embed the album art directly into each file's metadata. While this will take more space than the external-file approach, it is necessary for displaying album art in some media players (iPods, for example). Embedding Art Automatically --------------------------- To use the ``embedart`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``embedart`` extra .. code-block:: bash pip install "beets[embedart]" You'll also want to enable the :doc:`/plugins/fetchart` to obtain the images to be embedded. Art will be embedded after each album has its cover art set. This behavior can be disabled with the ``auto`` config option (see below). .. _image-similarity-check: Image Similarity ~~~~~~~~~~~~~~~~ When importing a lot of files with the ``auto`` option, one may be reluctant to overwrite existing embedded art for all of them. You can tell beets to avoid embedding images that are too different from the existing ones. This works by computing the perceptual hashes (PHASH_) of the two images and checking that the difference between the two does not exceed a threshold. You can set the threshold with the ``compare_threshold`` option. A threshold of 0 (the default) disables similarity checking and always embeds new images. Set the threshold to another number---we recommend between 10 and 100---to adjust the sensitivity of the comparison. The smaller the threshold number, the more similar the images must be. This feature requires ImageMagick_. Configuration ------------- To configure the plugin, make an ``embedart:`` section in your configuration file. The available options are: - **auto**: Enable automatic album art embedding. Default: ``yes``. - **compare_threshold**: How similar candidate art must be to existing art to be written to the file (see :ref:`image-similarity-check`). Default: 0 (disabled). - **ifempty**: Avoid embedding album art for files that already have art embedded. Default: ``no``. - **maxwidth**: A maximum width to downscale images before embedding them (the original image file is not altered). The resize operation reduces image width to at most ``maxwidth`` pixels. The height is recomputed so that the aspect ratio is preserved. See also :ref:`image-resizing` for further caveats about image resizing. Default: 0 (disabled). - **quality**: The JPEG quality level to use when compressing images (when ``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to use the default quality. 65–75 is usually a good starting point. The default behavior depends on the imaging tool used for scaling: ImageMagick tries to estimate the input image quality and uses 92 if it cannot be determined, and PIL defaults to 75. Default: 0 (disabled) - **remove_art_file**: Automatically remove the album art file for the album after it has been embedded. This option is best used alongside the :doc:`FetchArt </plugins/fetchart>` plugin to download art with the purpose of directly embedding it into the file's metadata without an "intermediate" album art file. Default: ``no``. - **clearart_on_import**: Enable automatic embedded art clearing. Default: ``no``. Note: ``compare_threshold`` option requires ImageMagick_, and ``maxwidth`` requires either ImageMagick_ or Pillow_. .. _imagemagick: https://imagemagick.org/ .. _phash: https://web.archive.org/web/*/http://www.fmwconcepts.com/misc_tests/perceptual_hash_test_results_510/index.html .. _pillow: https://github.com/python-pillow/Pillow Manually Embedding and Extracting Art ------------------------------------- The ``embedart`` plugin provides a couple of commands for manually managing embedded album art: - ``beet embedart [-f IMAGE] QUERY``: embed images in every track of the albums matching the query. If the ``-f`` (``--file``) option is given, then use a specific image file from the filesystem; otherwise, each album embeds its own currently associated album art. The command prompts for confirmation before making the change unless you specify the ``-y`` (``--yes``) option. - ``beet embedart [-u IMAGE_URL] QUERY``: embed image specified in the URL into every track of the albums matching the query. The ``-u`` (``--url``) option can be used to specify the URL of the image to be used. The command prompts for confirmation before making the change unless you specify the ``-y`` (``--yes``) option. - ``beet extractart [-a] [-n FILE] QUERY``: extracts the images for all albums matching the query. The images are placed inside the album folder. You can specify the destination file name using the ``-n`` option, but leave off the extension: it will be chosen automatically. The destination filename is specified using the ``art_filename`` configuration option. It defaults to ``cover`` if it's not specified via ``-o`` nor the config. Using ``-a``, the extracted image files are automatically associated with the corresponding album. - ``beet extractart -o FILE QUERY``: extracts the image from an item matching the query and stores it in a file. You have to specify the destination file using the ``-o`` option, but leave off the extension: it will be chosen automatically. - ``beet clearart QUERY``: removes all embedded images from all items matching the query. The command prompts for confirmation before making the change unless you specify the ``-y`` (``--yes``) option. The files listed for confirmation are the ones matching the query independently of having an embedded art. However, only the files with an embedded art are updated, leaving untouched the files without. ================================================ FILE: docs/plugins/embyupdate.rst ================================================ EmbyUpdate Plugin ================= ``embyupdate`` is a plugin that lets you automatically update Emby_'s library whenever you change your beets library. To use it, first enable the your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``embyupdate`` extra .. code-block:: bash pip install "beets[embyupdate]" Then, you'll want to configure the specifics of your Emby server. You can do that using an ``emby`` section in your ``config.yaml`` .. code-block:: yaml emby: host: localhost port: 8096 username: user apikey: apikey With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library. .. _emby: https://emby.media/ Configuration ------------- The available options under the ``emby:`` section are: - **host**: The Emby server host. You also can include ``http://`` or ``https://``. Default: ``localhost`` - **port**: The Emby server port. Default: 8096 - **username**: A username of an Emby user that is allowed to refresh the library. - **userid**: A user ID of an Emby user that is allowed to refresh the library. (This is only necessary for private users i.e. when the user is hidden from login screens) - **apikey**: An Emby API key for the user. - **password**: The password for the user. (This is only necessary if no API key is provided.) You can choose to authenticate either with ``apikey`` or ``password``, but only one of those two is required. ================================================ FILE: docs/plugins/export.rst ================================================ Export Plugin ============= The ``export`` plugin lets you get data from the items and export the content as JSON_, CSV_, or XML_. .. _csv: https://fileinfo.com/extension/csv .. _json: https://www.json.org .. _xml: https://fileinfo.com/extension/xml Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query </reference/query>` to get the data from your library. For example, run this: :: $ beet export beatles to print a JSON file containing information about your Beatles tracks. Command-Line Options -------------------- The ``export`` command has these command-line options: - ``--include-keys`` or ``-i``: Choose the properties to include in the output data. The argument is a comma-separated list of simple glob patterns where ``*`` matches any string. For example: :: $ beet export -i 'title,mb*' beatles will include the ``title`` property and all properties starting with ``mb``. You can add the ``-i`` option multiple times to the command line. - ``--library`` or ``-l``: Show data from the library database instead of the files' tags. - ``--album`` or ``-a``: Show data from albums instead of tracks (implies ``--library``). - ``--output`` or ``-o``: Path for an output file. If not informed, will print the data in the console. - ``--append``: Appends the data to the file instead of writing. - ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json, `jsonlines <https://jsonlines.org/>`_ and xml. Configuration ------------- To configure the plugin, make a ``export:`` section in your configuration file. For JSON export, these options are available under the ``json`` and ``jsonlines`` keys: - **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities. - **indent**: The number of spaces for indentation. - **separators**: A ``[item_separator, dict_separator]`` tuple. - **sort_keys**: Sorts the keys in JSON dictionaries. Those options match the options from the `Python json module`_. Similarly, these options are available for the CSV format under the ``csv`` key: - **delimiter**: Used as the separating character between fields. The default value is a comma (,). - **dialect**: The kind of CSV file to produce. The default is ``excel``. These options match the options from the `Python csv module`_. .. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params .. _python json module: https://docs.python.org/3/library/json.html#basic-usage The default options look like this: :: export: json: formatting: ensure_ascii: false indent: 4 separators: [',' , ': '] sort_keys: true csv: formatting: delimiter: ',' dialect: excel ================================================ FILE: docs/plugins/fetchart.rst ================================================ FetchArt Plugin =============== The ``fetchart`` plugin retrieves album art images from various sources on the Web and stores them as image files. To use the ``fetchart`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``fetchart`` extra .. code-block:: bash pip install "beets[fetchart]" Fetching Album Art During Import -------------------------------- When the plugin is enabled, it automatically tries to get album art for every album you import. By default, beets stores album art image files alongside the music files for an album in a file called ``cover.jpg``. To customize the name of this file, use the :ref:`art-filename` config option. To embed the art into the files' tags, use the :doc:`/plugins/embedart`. (You'll want to have both plugins enabled.) Configuration ------------- To configure the plugin, make a ``fetchart:`` section in your configuration file. The available options are: - **auto**: Enable automatic album art fetching during import. Default: ``yes``. - **cautious**: Pick only trusted album art by ignoring filenames that do not contain one of the keywords in ``cover_names``. Default: ``no``. - **cover_names**: Prioritize images containing words in this list. Default: ``cover front art album folder``. - **fallback**: Path to a fallback album art file if no album art was found otherwise. Default: ``None`` (disabled). - **minwidth**: Only images with a width bigger or equal to ``minwidth`` are considered as valid album art candidates. Default: 0. - **maxwidth**: A maximum image width to downscale fetched images if they are too big. The resize operation reduces image width to at most ``maxwidth`` pixels. The height is recomputed so that the aspect ratio is preserved. See the section on :ref:`cover-art-archive-maxwidth` below for additional information regarding the Cover Art Archive source. Default: 0 (no maximum is enforced). - **quality**: The JPEG quality level to use when compressing images (when ``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to use the default quality. 65–75 is usually a good starting point. The default behavior depends on the imaging tool used for scaling: ImageMagick tries to estimate the input image quality and uses 92 if it cannot be determined, and PIL defaults to 75. Default: 0 (disabled) - **max_filesize**: The maximum size of a target piece of cover art in bytes. When using an ImageMagick backend this sets ``-define jpeg:extent=max_filesize``. Using PIL this will reduce JPG quality by up to 50% to attempt to reach the target filesize. Neither method is *guaranteed* to reach the target size, however in most cases it should succeed. Default: 0 (disabled) - **enforce_ratio**: Only images with a width:height ratio of 1:1 are considered as valid album art candidates if set to ``yes``. It is also possible to specify a certain deviation to the exact ratio to still be considered valid. This can be done either in pixels (``enforce_ratio: 10px``) or as a percentage of the longer edge (``enforce_ratio: 0.5%``). Default: ``no``. - **sources**: List of sources to search for images. An asterisk ``*`` expands to all available sources. Default: ``filesystem coverart itunes amazon albumart``, i.e., everything but ``wikipedia``, ``google``, ``fanarttv`` and ``lastfm``. Enable those sources for more matches at the cost of some speed. They are searched in the given order, thus in the default config, no remote (Web) art source are queried if local art is found in the filesystem. To use a local image as fallback, move it to the end of the list. For even more fine-grained control over the search order, see the section on :ref:`album-art-sources` below. - **google_key**: Your Google API key (to enable the Google Custom Search backend). Default: None. - **google_engine**: The custom search engine to use. Default: The `beets custom search engine`_, which searches the entire web. - **fanarttv_key**: The personal API key for requesting art from fanart.tv. See below. - **lastfm_key**: The personal API key for requesting art from Last.fm. See below. - **store_source**: If enabled, fetchart stores the artwork's source in a flexible tag named ``art_source``. See below for the rationale behind this. Default: ``no``. - **high_resolution**: If enabled, fetchart retrieves artwork in the highest resolution it can find (warning: image files can sometimes reach >20MB). Default: ``no``. - **deinterlace**: If enabled, Pillow_ or ImageMagick_ backends are instructed to store cover art as non-progressive JPEG. You might need this if you use DAPs that don't support progressive images. Default: ``no``. - **cover_format**: If enabled, forced the cover image into the specified format. Most often, this will be either ``JPEG`` or ``PNG`` (see image-formats_). Also respects ``deinterlace``. Default: None (leave unchanged). Note: ``maxwidth`` and ``enforce_ratio`` options require either ImageMagick_ or Pillow_. .. note:: Previously, there was a ``remote_priority`` option to specify when to look for art on the filesystem. This is still respected, but a deprecation message will be shown until you replace this configuration with the new ``filesystem`` value in the ``sources`` array. .. _image-formats: .. admonition:: Image formats Other image formats are available, though the full list depends on your system and what backend you are using. If you're using the ImageMagick backend, you can use ``magick identify -list format`` to get a full list of all supported formats, and you can use the Python function PIL.features.pilinfo() to print a list of all supported formats in Pillow (``python3 -c 'import PIL.features as f; f.pilinfo()'``). .. _beets custom search engine: https://cse.google.com/cse?cx=001442825323518660753:hrh5ch1gjzm Here's an example that makes plugin select only images that contain ``front`` or ``back`` keywords in their filenames and prioritizes the iTunes source over others: :: fetchart: cautious: true cover_names: front back sources: itunes * Manually Fetching Album Art --------------------------- Use the ``fetchart`` command to download album art after albums have already been imported: :: $ beet fetchart [-f] [query] By default, the command will only look for album art when the album doesn't already have it; the ``-f`` or ``--force`` switch makes it search for art in Web databases regardless. If you specify a query, only matching albums will be processed; otherwise, the command processes every album in your library. Display Only Missing Album Art ------------------------------ Use the ``fetchart`` command with the ``-q`` switch in order to display only missing art: :: $ beet fetchart [-q] [query] By default the command will display all albums matching the ``query``. When the ``-q`` or ``--quiet`` switch is given, only albums for which artwork has been fetched, or for which artwork could not be found will be printed. .. _image-resizing: Image Resizing -------------- Beets can resize images using Pillow_, ImageMagick_, or a server-side resizing proxy. If either Pillow or ImageMagick is installed, beets will use those; otherwise, it falls back to the resizing proxy. If the resizing proxy is used, no resizing is performed for album art found on the filesystem---only downloaded art is resized. Server-side resizing can also be slower than local resizing, so consider installing one of the two backends for better performance. When using ImageMagick, beets looks for the ``convert`` executable in your path. On some versions of Windows, the program can be shadowed by a system-provided ``convert.exe``. On these systems, you may need to modify your ``%PATH%`` environment variable so that ImageMagick comes first or use Pillow instead. .. _album-art-sources: Album Art Sources ----------------- By default, this plugin searches for art in the local filesystem as well as on the Cover Art Archive, the iTunes Store, Amazon, and AlbumArt.org, in that order. You can reorder the sources or remove some to speed up the process using the ``sources`` configuration option. When looking for local album art, beets checks for image files located in the same folder as the music files you're importing. Beets prefers to use an image file whose name contains "cover", "front", "art", "album" or "folder", but in the absence of well-known names, it will use any image file in the same folder as your music files. For some of the art sources, the backend service can match artwork by various criteria. If you want finer control over the search order in such cases, you can use this alternative syntax for the ``sources`` option: :: fetchart: sources: - filesystem - coverart: release - itunes - coverart: releasegroup - '*' where listing a source without matching criteria will default to trying all available strategies. Entries of the forms ``coverart: release releasegroup`` and ``coverart: *`` are also valid. Currently, only the ``coverart`` source supports multiple criteria: namely, ``release`` and ``releasegroup``, which refer to the respective MusicBrainz IDs. When you choose to apply changes during an import, beets will search for art as described above. For "as-is" imports (and non-autotagged imports using the ``-A`` flag), beets only looks for art on the local filesystem. Google custom search ~~~~~~~~~~~~~~~~~~~~ To use the google image search backend you need to `register for a Google API key`_. Set the ``google_key`` configuration option to your key, then add ``google`` to the list of sources in your configuration. .. _register for a google api key: https://console.developers.google.com. Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine`` configuration option. The default engine searches the entire web for cover art. .. _define a custom search engine: https://programmablesearchengine.google.com/about/ Note that the Google custom search API is limited to 100 queries per day. After that, the fetchart plugin will fall back on other declared data sources. Fanart.tv ~~~~~~~~~ Although not strictly necessary right now, you might think about `registering a personal fanart.tv API key`_. Set the ``fanarttv_key`` configuration option to your key, then add ``fanarttv`` to the list of sources in your configuration. .. _registering a personal fanart.tv api key: https://fanart.tv/get-an-api-key/ More detailed information can be found `on their Wiki`_. Specifically, the personal key will give you earlier access to new art. .. _on their wiki: https://wiki.fanart.tv/General/personal%20api/ Last.fm ~~~~~~~ To use the Last.fm backend, you need to `register for a Last.fm API key`_. Set the ``lastfm_key`` configuration option to your API key, then add ``lastfm`` to the list of sources in your configuration. .. _register for a last.fm api key: https://www.last.fm/api/account/create Spotify ~~~~~~~ Spotify backend is enabled by default and will update album art if a valid Spotify album id is found. .. _beautifulsoup: https://www.crummy.com/software/BeautifulSoup/bs4/doc/ .. _pip: https://pip.pypa.io Cover Art URL ~~~~~~~~~~~~~ The ``fetchart`` plugin can also use a flexible attribute field ``cover_art_url`` where you can manually specify the image URL to be used as cover art. Any custom plugin can use this field to provide the cover art and ``fetchart`` will use it as a source. .. _cover-art-archive-maxwidth: Cover Art Archive Pre-sized Thumbnails -------------------------------------- The CAA provides pre-sized thumbnails of width 250, 500, and 1200 pixels. If you set the ``maxwidth`` option to one of these values, the corresponding image will be downloaded, saving ``beets`` the need to scale down the image. It can also speed up the downloading process, as some cover arts can sometimes be very large. Storing the Artwork's Source ---------------------------- Storing the current artwork's source might be used to narrow down ``fetchart`` commands. For example, if some albums have artwork placed manually in their directories that should not be replaced by a forced album art fetch, you could do ``beet fetchart -f ^art_source:filesystem`` The values written to ``art_source`` are the same names used in the ``sources`` configuration value. .. _imagemagick: https://imagemagick.org/ .. _pillow: https://github.com/python-pillow/Pillow ================================================ FILE: docs/plugins/filefilter.rst ================================================ FileFilter Plugin ================= The ``filefilter`` plugin allows you to skip files during import using regular expressions. To use the ``filefilter`` plugin, enable it in your configuration (see :ref:`using-plugins`). Configuration ------------- To configure the plugin, make a ``filefilter:`` section in your configuration file. The available options are: - **path**: A regular expression to filter files based on their path and name. Default: ``.*`` (import everything) - **album_path** and **singleton_path**: You may specify different regular expressions used for imports of albums and singletons. This way, you can automatically skip singletons when importing albums if the names (and paths) of the files are distinguishable via a regex. The regexes defined here take precedence over the global ``path`` option. Here's an example: :: filefilter: path: .*\d\d[^/]+$ # will only import files which names start with two digits album_path: .*\d\d[^/]+$ singleton_path: .*/(?!\d\d)[^/]+$ ================================================ FILE: docs/plugins/fish.rst ================================================ Fish Plugin =========== The ``fish`` plugin adds a ``beet fish`` command that creates a `Fish shell`_ tab-completion file named ``beet.fish`` in ``~/.config/fish/completions``. This enables tab-completion of ``beet`` commands for the `Fish shell`_. .. _fish shell: https://fishshell.com/ Configuration ------------- Enable the ``fish`` plugin (see :ref:`using-plugins`) on a system running the `Fish shell`_. Usage ----- Type ``beet fish`` to generate the ``beet.fish`` completions file at: ``~/.config/fish/completions/``. If you later install or disable plugins, run ``beet fish`` again to update the completions based on the enabled plugins. For users not accustomed to tab completion… After you type ``beet`` followed by a space in your shell prompt and then the ``TAB`` key, you should see a list of the beets commands (and their abbreviated versions) that can be invoked in your current environment. Similarly, typing ``beet -<TAB>`` will show you all the option flags available to you, which also applies to subcommands such as ``beet import -<TAB>``. If you type ``beet ls`` followed by a space and then the and the ``TAB`` key, you will see a list of all the album/track fields that can be used in beets queries. For example, typing ``beet ls ge<TAB>`` will complete to ``genres:`` and leave you ready to type the rest of your query. Options ------- In addition to beets commands, plugin commands, and option flags, the generated completions also include by default all the album/track fields. If you only want the former and do not want the album/track fields included in the generated completions, use ``beet fish -f`` to only generate completions for beets/plugin commands and option flags. If you want generated completions to also contain album/track field *values* for the items in your library, you can use the ``-e`` or ``--extravalues`` option. For example: ``beet fish -e genre`` or ``beet fish -e genre -e albumartist`` In the latter case, subsequently typing ``beet list genres: <TAB>`` will display a list of all the genres in your library and ``beet list albumartist: <TAB>`` will show a list of the album artists in your library. Keep in mind that all of these values will be put into the generated completions file, so use this option with care when specified fields contain a large number of values. Libraries with, for example, very large numbers of genres/artists may result in higher memory utilization, completion latency, et cetera. This option is not meant to replace database queries altogether. By default, the completion file will be generated at ``~/.config/fish/completions/``. If you want to save it somewhere else, you can use the ``-o`` or ``--output`` option. ================================================ FILE: docs/plugins/freedesktop.rst ================================================ Freedesktop Plugin ================== The ``freedesktop`` plugin created .directory files in your album folders. This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails` with the ``dolphin`` option enabled. ================================================ FILE: docs/plugins/fromfilename.rst ================================================ FromFilename Plugin =================== The ``fromfilename`` plugin helps to tag albums that are missing tags altogether but where the filenames contain useful information like the artist and title. When you attempt to import a track that's missing a title, this plugin will look at the track's filename and guess its track number, title, and artist. These will be used to search in MusicBrainz and match track ordering. To use the ``fromfilename`` plugin, enable it in your configuration (see :ref:`using-plugins`). ================================================ FILE: docs/plugins/ftintitle.rst ================================================ FtInTitle Plugin ================ The ``ftintitle`` plugin automatically moves "featured" artists from the ``artist`` field to the ``title`` field. According to `MusicBrainz style`_, featured artists are part of the artist field. That means that, if you tag your music using MusicBrainz, you'll have tracks in your library like "Tellin' Me Things" by the artist "Blakroc feat. RZA". If you prefer to tag this as "Tellin' Me Things feat. RZA" by "Blakroc", then this plugin is for you. To use the ``ftintitle`` plugin, enable it in your configuration (see :ref:`using-plugins`). Configuration ------------- To configure the plugin, make a ``ftintitle:`` section in your configuration file. The available options are: - **auto**: Enable metadata rewriting during import. Default: ``yes``. - **drop**: Remove featured artists entirely instead of adding them to the title field. Default: ``no``. - **format**: Defines the format for the featuring X part of the new title field. In this format the ``{0}`` is used to define where the featured artists are placed. Default: ``feat. {0}`` - **keep_in_artist**: Keep the featuring X part in the artist field. This can be useful if you still want to be able to search for features in the artist field. Default: ``no``. - **preserve_album_artist**: If the artist and the album artist are the same, skip the ftintitle processing. Default: ``yes``. - **custom_words**: List of additional words that will be treated as a marker for artist features. Default: ``[]``. - **bracket_keywords**: Controls where the featuring text is inserted when the title includes bracketed qualifiers such as ``(Remix)`` or ``[Live]``. FtInTitle inserts the new text before the first bracket whose contents match any of these keywords. Supply a list of words to fine-tune the behavior or set the list to ``[]`` to match *any* bracket regardless of its contents. Default: :: ["abridged", "acapella", "club", "demo", "edit", "edition", "extended", "instrumental", "live", "mix", "radio", "release", "remaster", "remastered", "remix", "rmx", "unabridged", "unreleased", "version", "vip"] Path Template Values -------------------- This plugin provides the ``album_artist_no_feat`` :ref:`template value <templ_plugins>` that you can use in your :ref:`path-format-config` in ``paths.default``. Any ``custom_words`` in the configuration are taken into account. Running Manually ---------------- From the command line, type: :: $ beet ftintitle [QUERY] The query is optional; if it's left off, the transformation will be applied to your entire collection. Use the ``-d`` flag to remove featured artists (equivalent of the ``drop`` config option). .. _musicbrainz style: https://musicbrainz.org/doc/Style ================================================ FILE: docs/plugins/fuzzy.rst ================================================ Fuzzy Search Plugin =================== The ``fuzzy`` plugin provides a prefixed query that searches your library using fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title. First, enable the plugin named ``fuzzy`` (see :ref:`using-plugins`). You'll then be able to use the ``~`` prefix to use fuzzy matching: :: $ beet ls '~Vareoldur' Sigur Rós - Valtari - Varðeldur Configuration ------------- To configure the plugin, make a ``fuzzy:`` section in your configuration file. The available options are: - **threshold**: The "sensitivity" of the fuzzy match. A value of 1.0 will show only perfect matches and a value of 0.0 will match everything. Default: 0.7. - **prefix**: The character used to designate fuzzy queries. Default: ``~``, which may need to be escaped in some shells. ================================================ FILE: docs/plugins/hook.rst ================================================ Hook Plugin =========== Internally, beets uses *events* to tell plugins when something happens. For example, one event fires when the importer finishes processes a song, and another triggers just before the ``beet`` command exits. The ``hook`` plugin lets you run commands in response to these events. .. _hook-configuration: Configuration ------------- To configure the plugin, make a ``hook`` section in your configuration file. The available options are: - **hooks**: A list of events and the commands to run (see :ref:`individual-hook-configuration`). Default: Empty. .. _individual-hook-configuration: Configuring Each Hook ~~~~~~~~~~~~~~~~~~~~~ Each element under ``hooks`` should have these keys: - **event**: The name of the event that will trigger this hook. See the :ref:`plugin events <plugin_events>` documentation for a list of possible values. - **command**: The command to run when this hook executes. .. _command-substitution: Command Substitution ~~~~~~~~~~~~~~~~~~~~ Commands can access the parameters of events using `Python string formatting`_. Use ``{name}`` in your command and the plugin will substitute it with the named value. The name can also refer to a field, as in ``{album.path}``. .. _python string formatting: https://peps.python.org/pep-3101/ You can find a list of all available events and their arguments in the :ref:`plugin events <plugin_events>` documentation. Example Configuration --------------------- .. code-block:: yaml hook: hooks: # Output on exit: # beets just exited! # have a nice day! - event: cli_exit command: echo "beets just exited!" - event: cli_exit command: echo "have a nice day!" # Output on item import: # importing "<file_name_here>" # Where <file_name_here> is the item being imported - event: item_imported command: echo "importing \"{item.path}\"" # Output on write: # writing to "<file_name_here>" # Where <file_name_here> is the file being written to - event: write command: echo "writing to {path}" ================================================ FILE: docs/plugins/ihate.rst ================================================ IHate Plugin ============ The ``ihate`` plugin allows you to automatically skip things you hate during import or warn you about them. You specify queries (see :doc:`/reference/query`) and the plugin skips (or warns about) albums or items that match any query. To use the ``ihate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Configuration ------------- To configure the plugin, make an ``ihate:`` section in your configuration file. The available options are: - **skip**: Never import items and albums that match a query in this list. Default: ``[]`` (empty list). - **warn**: Print a warning message for matches in this list of queries. Default: ``[]``. Here's an example: :: ihate: warn: - artist:rnb - genres:soul # Only warn about tribute albums in rock genre. - genres:rock album:tribute skip: - genres::russian\srock - genres:polka - artist:manowar - album:christmas The plugin trusts your decision in "as-is" imports. ================================================ FILE: docs/plugins/importadded.rst ================================================ ImportAdded Plugin ================== The ``importadded`` plugin is useful when an existing collection is imported and the time when albums and items were added should be preserved. To use the ``importadded`` plugin, enable it in your configuration (see :ref:`using-plugins`). Usage ----- The :abbr:`mtime (modification time)` of files that are imported into the library are assumed to represent the time when the items were originally added. The ``item.added`` field is populated as follows: - For singleton items with no album, ``item.added`` is set to the item's file mtime before it was imported. - For items that are part of an album, ``album.added`` and ``item.added`` are set to the oldest mtime of the files in the album before they were imported. The mtime of album directories is ignored. This plugin can optionally be configured to also preserve mtimes at import using the ``preserve_mtimes`` option. When ``preserve_write_mtimes`` option is set, this plugin preserves mtimes after each write to files using the ``item.added`` attribute. File modification times are preserved as follows: - For all items: - ``item.mtime`` is set to the mtime of the file from which the item is imported from. - The mtime of the file ``item.path`` is set to ``item.mtime``. Note that there is no ``album.mtime`` field in the database and that the mtime of album directories on disk aren't preserved. Configuration ------------- To configure the plugin, make an ``importadded:`` section in your configuration file. There are two options available: - **preserve_mtimes**: After importing files, re-set their mtimes to their original value. Default: ``no``. - **preserve_write_mtimes**: After writing files, re-set their mtimes to their original value. Default: ``no``. Reimport -------- This plugin will skip reimported singleton items and reimported albums and all of their items. ================================================ FILE: docs/plugins/importfeeds.rst ================================================ ImportFeeds Plugin ================== This plugin helps you keep track of newly imported music in your library. To use the ``importfeeds`` plugin, enable it in your configuration (see :ref:`using-plugins`). Configuration ------------- To configure the plugin, make an ``importfeeds:`` section in your configuration file. The available options are: - **absolute_path**: Use absolute paths instead of relative paths. Some applications may need this to work properly. Default: ``no``. - **dir**: The output directory. Default: Your beets library directory. - **formats**: Select the kind of output. Use one or more of: - **m3u**: Catalog the imports in a centralized playlist. - **m3u_multi**: Create a new playlist for each import (uniquely named by appending the date and track/album name). - **m3u_session**: Create a new playlist for each import session. The file is named as ``m3u_name`` appending the date and time the import session was started. - **link**: Create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon. - **echo**: Do not write a playlist file at all, but echo a list of new file paths to the terminal. Default: None. - **m3u_name**: Playlist name used by the ``m3u`` format and as a prefix used by the ``m3u_session`` format. Default: ``imported.m3u``. - **relative_to**: Make the m3u paths relative to another folder than where the playlist is being written. If you're using importfeeds to generate a playlist for MPD, you should set this to the root of your music library. Default: None. Here's an example configuration for this plugin: :: importfeeds: formats: m3u link dir: ~/imports/ relative_to: ~/Music/ m3u_name: newfiles.m3u ================================================ FILE: docs/plugins/importsource.rst ================================================ ImportSource Plugin =================== The ``importsource`` plugin adds a ``source_path`` field to every item imported to the library which stores the original media files' paths. Using this plugin makes most sense when the general importing workflow is using ``beet import --copy``. Additionally the plugin interactively suggests deletion of original source files whenever items are removed from the Beets library. To enable it, add ``importsource`` to the list of plugins in your configuration (see :ref:`using-plugins`). Tracking Source Paths --------------------- The primary use case for the plugin is tracking the original location of imported files using the ``source_path`` field. Consider this scenario: you've imported all directories in your current working directory using: .. code-block:: bash beet import --flat --copy */ Later, for instance if the import didn't complete successfully, you'll need to rerun the import but don't want Beets to re-process the already successfully imported directories. You can view which files were successfully imported using: .. code-block:: bash beet ls source_path:$PWD --format='$source_path' To extract just the directory names, pipe the output to standard UNIX utilities: .. code-block:: bash beet ls source_path:$PWD --format='$source_path' | awk -F / '{print $(NF-1)}' | sort -u This might help to find out what's left to be imported. Removal Suggestion ------------------ Another feature of the plugin is suggesting removal of original source files when items are deleted from your library. Consider this scenario: you imported an album using: .. code-block:: bash beet import --copy --flat ~/Desktop/interesting-album-to-check/ After listening to that album and deciding it wasn't good, you want to delete it from your library as well as from your ``~/Desktop``, so you run: .. code-block:: bash beet remove --delete source_path:$HOME/Desktop/interesting-album-to-check After approving the deletion, the plugin will prompt: .. code-block:: text The item: <music-library>/Interesting Album/01 Interesting Song.flac is originated from: <HOME>/Desktop/interesting-album-to-check/01-interesting-song.flac What would you like to do? Delete the item's source, Recursively delete the source's directory, do Nothing, do nothing and Stop suggesting to delete items from this album? Configuration ------------- To configure the plugin, make an ``importsource:`` section in your configuration file. There is one option available: - **suggest_removal**: By default ``importsource`` suggests to remove the original directories / files from which the items were imported whenever library items (and files) are removed. To disable these prompts set this option to ``no``. Default: ``yes``. ================================================ FILE: docs/plugins/index.rst ================================================ Plugins ======= Plugins extend beets' core functionality. They add new commands, fetch additional data during import, provide new metadata sources, and much more. If beets by itself doesn't do what you want it to, you may just need to enable a plugin---or, if you want to do something new, :doc:`writing a plugin </dev/plugins/index>` is easy if you know a little Python. .. _using-plugins: Using Plugins ------------- To use one of the plugins included with beets (see the rest of this page for a list), just use the ``plugins`` option in your :doc:`config.yaml </reference/config>` file: .. code-block:: sh plugins: musicbrainz inline convert web The value for ``plugins`` can be a space-separated list of plugin names or a YAML list like ``[foo, bar]``. You can see which plugins are currently enabled by typing ``beet version``. Each plugin has its own set of options that can be defined in a section bearing its name: .. code-block:: yaml plugins: musicbrainz inline convert web convert: auto: true Some plugins have special dependencies that you'll need to install. The documentation page for each plugin will list them in the setup instructions. For some, you can use ``pip``'s "extras" feature to install the dependencies: .. code-block:: sh pip install "beets[fetchart,lyrics,lastgenre]" .. _metadata-source-plugin-configuration: Using Metadata Source Plugins ----------------------------- We provide several :ref:`autotagger_extensions` that fetch metadata from online databases. They share the following configuration options: .. include:: ./shared_metadata_source_config.rst .. toctree:: :hidden: absubmit acousticbrainz advancedrewrite albumtypes aura autobpm badfiles bareasc beatport bpd bpm bpsync bucket chroma convert deezer discogs duplicates edit embedart embyupdate export fetchart filefilter fish freedesktop fromfilename ftintitle fuzzy hook ihate importadded importsource importfeeds info inline ipfs keyfinder kodiupdate lastgenre lastimport limit listenbrainz loadext lyrics mbsync metasync missing mpdstats mpdupdate musicbrainz mbcollection mbpseudo mbsubmit parentwork permissions play playlist plexupdate random replace replaygain rewrite scrub smartplaylist sonosupdate spotify subsonicplaylist subsonicupdate substitute the thumbnails titlecase types unimported web zero .. _autotagger_extensions: Autotagger Extensions --------------------- :doc:`chroma <chroma>` Use acoustic fingerprinting to identify audio files with missing or incorrect metadata. :doc:`deezer <deezer>` Search for releases in the Deezer_ database. :doc:`discogs <discogs>` Search for releases in the Discogs_ database. :doc:`fromfilename <fromfilename>` Guess metadata for untagged tracks from their filenames. :doc:`musicbrainz <musicbrainz>` Search for releases in the MusicBrainz_ database. :doc:`mbpseudo <mbpseudo>` Search for releases and pseudo-releases in the MusicBrainz_ database. :doc:`spotify <spotify>` Search for releases in the Spotify_ database. .. _deezer: https://www.deezer.com/en/ .. _discogs: https://www.discogs.com .. _musicbrainz: https://www.musicbrainz.com .. _spotify: https://open.spotify.com/ Metadata -------- :doc:`absubmit <absubmit>` Analyse audio with the streaming_extractor_music_ program and submit the metadata to an AcousticBrainz server :doc:`acousticbrainz <acousticbrainz>` Fetch various AcousticBrainz metadata :doc:`autobpm <autobpm>` Use Librosa_ to calculate the BPM from the audio. :doc:`bpm <bpm>` Measure tempo using keystrokes. :doc:`bpsync <bpsync>` Fetch updated metadata from Beatport. :doc:`edit <edit>` Edit metadata from a text editor. :doc:`embedart <embedart>` Embed album art images into files' metadata. :doc:`fetchart <fetchart>` Fetch album cover art from various sources. :doc:`ftintitle <ftintitle>` Move "featured" artists from the artist field to the title field. :doc:`keyfinder <keyfinder>` Use the KeyFinder_ program to detect the musical key from the audio. :doc:`importadded <importadded>` Use file modification times for guessing the value for the ``added`` field in the database. :doc:`lastgenre <lastgenre>` Fetch genres based on Last.fm tags. :doc:`lastimport <lastimport>` Collect play counts from Last.fm. :doc:`lyrics <lyrics>` Automatically fetch song lyrics. :doc:`mbsync <mbsync>` Fetch updated metadata from MusicBrainz. :doc:`metasync <metasync>` Fetch metadata from local or remote sources :doc:`mpdstats <mpdstats>` Connect to MPD_ and update the beets library with play statistics (last_played, play_count, skip_count, rating). :doc:`parentwork <parentwork>` Fetch work titles and works they are part of. :doc:`replaygain <replaygain>` Calculate volume normalization for players that support it. :doc:`scrub <scrub>` Clean extraneous metadata from music files. :doc:`zero <zero>` Nullify fields by pattern or unconditionally. .. _keyfinder: https://www.ibrahimshaath.co.uk/keyfinder/ .. _librosa: https://github.com/librosa/librosa/ .. _streaming_extractor_music: https://acousticbrainz.org/download Path Formats ------------ :doc:`albumtypes <albumtypes>` Format album type in path formats. :doc:`bucket <bucket>` Group your files into bucket directories that cover different field values ranges. :doc:`inline <inline>` Use Python snippets to customize path format strings. :doc:`rewrite <rewrite>` Substitute values in path formats. :doc:`advancedrewrite <advancedrewrite>` Substitute field values for items matching a query. :doc:`substitute <substitute>` As an alternative to :doc:`rewrite <rewrite>`, use this plugin. The main difference between them is that this plugin never modifies the files metadata. :doc:`the <the>` Move patterns in path formats (i.e., move "a" and "the" to the end). Interoperability ---------------- :doc:`aura <aura>` A server implementation of the AURA_ specification. :doc:`badfiles <badfiles>` Check audio file integrity. :doc:`embyupdate <embyupdate>` Automatically notifies Emby_ whenever the beets library changes. :doc:`fish <fish>` Adds `Fish shell`_ tab autocompletion to ``beet`` commands. :doc:`importfeeds <importfeeds>` Keep track of imported files via ``.m3u`` playlist file(s) or symlinks. :doc:`ipfs <ipfs>` Import libraries from friends and get albums from them via ipfs. :doc:`kodiupdate <kodiupdate>` Automatically notifies Kodi_ whenever the beets library changes. :doc:`mpdupdate <mpdupdate>` Automatically notifies MPD_ whenever the beets library changes. :doc:`play <play>` Play beets queries in your music player. :doc:`playlist <playlist>` Use M3U playlists to query the beets library. :doc:`plexupdate <plexupdate>` Automatically notifies Plex_ whenever the beets library changes. :doc:`smartplaylist <smartplaylist>` Generate smart playlists based on beets queries. :doc:`sonosupdate <sonosupdate>` Automatically notifies Sonos_ whenever the beets library changes. :doc:`thumbnails <thumbnails>` Get thumbnails with the cover art on your album folders. :doc:`subsonicupdate <subsonicupdate>` Automatically notifies Subsonic_ whenever the beets library changes. .. _aura: https://auraspec.readthedocs.io/en/latest/ .. _emby: https://emby.media .. _fish shell: https://fishshell.com/ .. _kodi: https://kodi.tv .. _plex: https://watch.plex.tv/ .. _sonos: https://www.sonos.com/ .. _subsonic: https://www.subsonic.org/pages/index.jsp Miscellaneous ------------- :doc:`bareasc <bareasc>` Search albums and tracks with bare ASCII string matching. :doc:`bpd <bpd>` A music player for your beets library that emulates MPD_ and is compatible with `MPD clients`_. :doc:`convert <convert>` Transcode music and embed album art while exporting to a different directory. :doc:`duplicates <duplicates>` List duplicate tracks or albums. :doc:`export <export>` Export data from queries to a format. :doc:`filefilter <filefilter>` Automatically skip files during the import process based on regular expressions. :doc:`fuzzy <fuzzy>` Search albums and tracks with fuzzy string matching. :doc:`hook <hook>` Run a command when an event is emitted by beets. :doc:`ihate <ihate>` Automatically skip albums and tracks during the import process. :doc:`info <info>` Print music files' tags to the console. :doc:`loadext <loadext>` Load SQLite extensions. :doc:`mbcollection <mbcollection>` Maintain your MusicBrainz collection list. :doc:`mbsubmit <mbsubmit>` Print an album's tracks in a MusicBrainz-friendly format. :doc:`missing <missing>` List missing tracks. mstream_ A music streaming server + webapp that can be used alongside beets. :doc:`random <random>` Randomly choose albums and tracks from your library. :doc:`spotify <spotify>` Create Spotify playlists from the Beets library. :doc:`types <types>` Declare types for flexible attributes. :doc:`web <web>` An experimental Web-based GUI for beets. .. _mpd: https://www.musicpd.org/ .. _mpd clients: https://mpd.fandom.com/wiki/Clients .. _mstream: https://github.com/IrosTheBeggar/mStream .. _other-plugins: Other Plugins ------------- In addition to the plugins that come with beets, there are several plugins that are maintained by the beets community. To use an external plugin, there are two options for installation: - Make sure it's in the Python path (known as ``sys.path`` to developers). This just means the plugin has to be installed on your system (e.g., with a ``setup.py`` script or a command like ``pip`` or ``easy_install``). - Set the ``pluginpath`` config variable to point to the directory containing the plugin. (See :doc:`/reference/config`.) Once the plugin is installed, enable it by placing its name on the ``plugins`` line in your config file. Here are a few of the plugins written by the beets community: beets-alternatives_ Manages external files. beet-amazon_ Adds Amazon.com as a tagger data source. beets-artistcountry_ Fetches the artist's country of origin from MusicBrainz. beets-autofix_ Automates repetitive tasks to keep your library in order. beets-autogenre_ Assigns genres to your library items using the :doc:`lastgenre <lastgenre>` and beets-xtractor_ plugins as well as additional rules. beets-audible_ Adds Audible as a tagger data source and provides other features for managing audiobook collections. beets-barcode_ Lets you scan or enter barcodes for physical media to search for their metadata. beetcamp_ Enables **bandcamp.com** autotagger with a fairly extensive amount of metadata. beetstream_ Server implementation of the `Subsonic API`_ specification, serving the beets library and (:doc:`smartplaylist <smartplaylist>` plugin generated) M3U playlists, allowing you to stream your music on a multitude of clients. beets-bpmanalyser_ Analyses songs and calculates their tempo (BPM). beets-check_ Automatically checksums your files to detect corruption. `A cmus plugin`_ Integrates with the cmus_ console music player. beets-copyartifacts_ Helps bring non-music files along during import. beets-describe_ Gives you the full picture of a single attribute of your library items. drop2beets_ Automatically imports singles as soon as they are dropped in a folder (using Linux's ``inotify``). You can also set a sub-folders hierarchy to set flexible attributes by the way. dsedivec_ Has two plugins: ``edit`` and ``moveall``. beets-filetote_ Helps bring non-music extra files, attachments, and artifacts during imports and CLI file manipulation actions (``beet move``, etc.). beets-fillmissing_ Interactively prompts you to fill in missing or incomplete metadata fields for music tracks. beets-follow_ Lets you check for new albums from artists you like. beetFs_ Is a FUSE filesystem for browsing the music in your beets library. (Might be out of date.) beets-goingrunning_ Generates playlists to go with your running sessions. beets-ibroadcast_ Uploads tracks to the iBroadcast_ cloud service. beets-id3extract_ Maps arbitrary ID3 tags to beets custom fields. beets-importreplace_ Lets you perform regex replacements on incoming metadata. beets-jiosaavn_ Adds JioSaavn.com as a tagger data source. beets-more_ Finds versions of indexed releases with more tracks, like deluxe and anniversary editions. beets-mosaic_ Generates a montage of a mosaic from cover art. beets-mpd-utils_ Plugins to interface with MPD_. Comes with ``mpd_tracker`` (track play/skip counts from MPD) and ``mpd_dj`` (auto-add songs to your queue.) beets-noimport_ Adds and removes directories from the incremental import skip list. beets-originquery_ Augments MusicBrainz queries with locally-sourced data to improve autotagger results. beets-plexsync_ Allows you to sync your Plex library with your beets library, create smart playlists in Plex, and import online playlists (from services like Spotify) into Plex. beets-setlister_ Generate playlists from the setlists of a given artist. beet-summarize_ Can compute lots of counts and statistics about your music library. beets-usertag_ Lets you use keywords to tag and organize your music. beets-webm3u_ Serves the (:doc:`smartplaylist <smartplaylist>` plugin generated) M3U playlists via HTTP. beets-webrouter_ Serves multiple beets webapps (e.g. :doc:`web <web>`, beets-webm3u_, beetstream_, :doc:`aura <aura>`) using a single command/process/host/port, each under a different path. whatlastgenre_ Fetches genres from various music sites. beets-xtractor_ Extracts low- and high-level musical information from your songs. beets-ydl_ Downloads audio from youtube-dl sources and import into beets. beets-ytimport_ Download and import your liked songs from YouTube into beets. beets-yearfixer_ Attempts to fix all missing ``original_year`` and ``year`` fields. beets-youtube_ Adds YouTube Music as a tagger data source. .. _a cmus plugin: https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py .. _beet-amazon: https://github.com/jmwatte/beet-amazon .. _beet-musicbrainz-collection: https://github.com/jeffayle/Beet-MusicBrainz-Collection/ .. _beet-summarize: https://github.com/steven-murray/beet-summarize .. _beetcamp: https://github.com/snejus/beetcamp .. _beetfs: https://github.com/jbaiter/beetfs .. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives .. _beets-artistcountry: https://github.com/agrausem/beets-artistcountry .. _beets-audible: https://github.com/Neurrone/beets-audible .. _beets-autofix: https://github.com/adamjakab/BeetsPluginAutofix .. _beets-autogenre: https://github.com/mgoltzsche/beets-autogenre .. _beets-barcode: https://github.com/8h2a/beets-barcode .. _beets-bpmanalyser: https://github.com/adamjakab/BeetsPluginBpmAnalyser .. _beets-check: https://github.com/geigerzaehler/beets-check .. _beets-copyartifacts: https://github.com/adammillerio/beets-copyartifacts .. _beets-describe: https://github.com/adamjakab/BeetsPluginDescribe .. _beets-filetote: https://github.com/gtronset/beets-filetote .. _beets-fillmissing: https://github.com/amiv1/beets-fillmissing .. _beets-follow: https://github.com/nolsto/beets-follow .. _beets-goingrunning: https://pypi.org/project/beets-goingrunning .. _beets-ibroadcast: https://github.com/ctrueden/beets-ibroadcast .. _beets-id3extract: https://github.com/bcotton/beets-id3extract .. _beets-importreplace: https://github.com/edgars-supe/beets-importreplace .. _beets-jiosaavn: https://github.com/arsaboo/beets-jiosaavn .. _beets-more: https://forgejo.sny.sh/sun/beetsplug/src/branch/main/more .. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic .. _beets-mpd-utils: https://github.com/thekakkun/beets-mpd-utils .. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport .. _beets-originquery: https://github.com/x1ppy/beets-originquery .. _beets-plexsync: https://github.com/arsaboo/beets-plexsync .. _beets-setlister: https://github.com/tomjaspers/beets-setlister .. _beets-usertag: https://github.com/edgars-supe/beets-usertag .. _beets-webm3u: https://github.com/mgoltzsche/beets-webm3u .. _beets-webrouter: https://github.com/mgoltzsche/beets-webrouter .. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor .. _beets-ydl: https://github.com/vmassuchetto/beets-ydl .. _beets-yearfixer: https://github.com/adamjakab/BeetsPluginYearFixer .. _beets-youtube: https://github.com/arsaboo/beets-youtube .. _beets-ytimport: https://github.com/mgoltzsche/beets-ytimport .. _beetstream: https://github.com/BinaryBrain/Beetstream .. _cmus: https://sourceforge.net/projects/cmus/ .. _drop2beets: https://github.com/martinkirch/drop2beets .. _dsedivec: https://github.com/dsedivec/beets-plugins .. _ibroadcast: https://ibroadcast.com/ .. _subsonic api: https://www.subsonic.org/pages/api.jsp .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets ================================================ FILE: docs/plugins/info.rst ================================================ Info Plugin =========== The ``info`` plugin provides a command that dumps the current tag values for any file format supported by beets. It works like a supercharged version of mp3info_ or id3v2_. Enable the ``info`` plugin in your configuration (see :ref:`using-plugins`) and then type: :: $ beet info /path/to/music.flac and the plugin will enumerate all the tags in the specified file. It also accepts multiple filenames in a single command-line. You can also enter a :doc:`query </reference/query>` to inspect music from your library: :: $ beet info beatles If you just want to see specific properties you can use the ``--include-keys`` option to filter them. The argument is a comma-separated list of field names. For example: :: $ beet info -i 'title,mb_artistid' beatles Will only show the ``title`` and ``mb_artistid`` properties. You can add the ``-i`` option multiple times to the command line. Additional command-line options include: - ``--library`` or ``-l``: Show data from the library database instead of the files' tags. - ``--album`` or ``-a``: Show data from albums instead of tracks (implies ``--library``). - ``--summarize`` or ``-s``: Merge all the information from multiple files into a single list of values. If the tags differ across the files, print ``[various]``. - ``--format`` or ``-f``: Specify a specific format with which to print every item. This uses the same template syntax as beets’ :doc:`path formats </reference/pathformat>`. - ``--keys-only`` or ``-k``: Show the name of the tags without the values. .. _id3v2: https://sourceforge.net/projects/id3v2/ .. _mp3info: https://www.ibiblio.org/mp3info/ ================================================ FILE: docs/plugins/inline.rst ================================================ Inline Plugin ============= The ``inline`` plugin lets you use Python to customize your path formats. Using it, you can define template fields in your beets configuration file and refer to them from your template strings in the ``paths:`` section (see :doc:`/reference/config/`). To use the ``inline`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, make a ``item_fields:`` block in your config file. Under this key, every line defines a new template field; the key is the name of the field (you'll use the name to refer to the field in your templates) and the value is a Python expression or function body. The Python code has all of a track's fields in scope, so you can refer to any normal attributes (such as ``artist`` or ``title``) as Python variables. Here are a couple of examples of expressions: :: item_fields: initial: albumartist[0].upper() + u'.' disc_and_track: f"{disc:02d}.{track:02d}" if disctotal > 1 else f"{track:02d}" Note that YAML syntax allows newlines in values if the subsequent lines are indented. These examples define ``$initial`` and ``$disc_and_track`` fields that can be referenced in path templates like so: :: paths: default: $initial/$artist/$album%aunique{}/$disc_and_track $title Block Definitions ----------------- If you need to use statements like ``import``, you can write a Python function body instead of a single expression. In this case, you'll need to ``return`` a result for the value of the path field, like so: :: item_fields: filename: | import os from beets.util import bytestring_path return bytestring_path(os.path.basename(path)) You might want to use the YAML syntax for "block literals," in which a leading ``|`` character indicates a multi-line block of text. Album Fields ------------ The above examples define fields for *item* templates, but you can also define fields for *album* templates. Use the ``album_fields`` configuration section. In this context, all existing album fields are available as variables along with ``items``, which is a list of items in the album. This example defines a ``$bitrate`` field for albums as the average of the tracks' fields: :: album_fields: bitrate: | total = 0 for item in items: total += item.bitrate return total / len(items) ================================================ FILE: docs/plugins/ipfs.rst ================================================ IPFS Plugin =========== The ``ipfs`` plugin makes it easy to share your library and music with friends. The plugin uses ipfs_ for storing the library and file content. .. _ipfs: https://about.ipfs.io/ Installation ------------ This plugin requires go-ipfs_ to be running as a daemon and that the associated ``ipfs`` command is on the user's ``$PATH``. .. _go-ipfs: https://github.com/ipfs/kubo Once you have the client installed, enable the ``ipfs`` plugin in your configuration (see :ref:`using-plugins`). Usage ----- This plugin can store and retrieve music individually, or it can share entire library databases. Adding ~~~~~~ To add albums to ipfs, making them shareable, use the ``-a`` or ``--add`` flag. If used without arguments it will add all albums in the local library. When added, all items and albums will get an "ipfs" field in the database containing the hash of that specific file/folder. Newly imported albums will be added automatically to ipfs by default (see below). Retrieving ~~~~~~~~~~ You can give the ipfs hash for some music to a friend. They can get that album from ipfs, and import it into beets, using the ``-g`` or ``--get`` flag. If the argument passed to the ``-g`` flag isn't an ipfs hash, it will be used as a query instead, getting all albums matching the query. Sharing Libraries ~~~~~~~~~~~~~~~~~ Using the ``-p`` or ``--publish`` flag, a copy of the local library will be published to ipfs. Only albums/items with ipfs records in the database will published, and local paths will be stripped from the library. A hash of the library will be returned to the user. A friend can then import this remote library by using the ``-i`` or ``--import`` flag. To tag an imported library with a specific name by passing a name as the second argument to ``-i,`` after the hash. The content of all remote libraries will be combined into an additional library as long as the content doesn't already exist in the joined library. When remote libraries has been imported you can search them by using the ``-l`` or ``--list`` flag. The hash of albums matching the query will be returned, and this can then be used with ``-g`` to fetch and import the album to the local library. Ipfs can be mounted as a FUSE file system. This means that music in a remote library can be streamed directly, without importing them to the local library first. If the ``/ipfs`` folder is mounted then matching queries will be sent to the :doc:`/plugins/play` using the ``-m`` or ``--play`` flag. Configuration ------------- The ipfs plugin will automatically add imported albums to ipfs and add those hashes to the database. This can be turned off by setting the ``auto`` option in the ``ipfs:`` section of the config to ``no``. If the setting ``nocopy`` is true (defaults false) then the plugin will pass the ``--nocopy`` option when adding things to ipfs. If the filestore option of ipfs is enabled this will mean files are neither removed from beets nor copied somewhere else. ================================================ FILE: docs/plugins/keyfinder.rst ================================================ Key Finder Plugin ================= The ``keyfinder`` plugin uses either the KeyFinder_ or keyfinder-cli_ program to detect the musical key of a track from its audio data and store it in the ``initial_key`` field of your database. It does so automatically when importing music or through the ``beet keyfinder [QUERY]`` command. To use the ``keyfinder`` plugin, enable it in your configuration (see :ref:`using-plugins`). Configuration ------------- To configure the plugin, make a ``keyfinder:`` section in your configuration file. The available options are: - **auto**: Analyze every file on import. Otherwise, you need to use the ``beet keyfinder`` command explicitly. Default: ``yes`` - **bin**: The name of the program use for key analysis. You can use either KeyFinder_ or keyfinder-cli_. If you installed the KeyFinder GUI on a Mac, for example, you want something like ``/Applications/KeyFinder.app/Contents/MacOS/KeyFinder``. If using keyfinder-cli_, the binary must be named ``keyfinder-cli``. Default: ``KeyFinder`` (i.e., search for the program in your ``$PATH``).. - **overwrite**: Calculate a key even for files that already have an ``initial_key`` value. Default: ``no``. .. _keyfinder: https://www.ibrahimshaath.co.uk/keyfinder/ .. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli/ ================================================ FILE: docs/plugins/kodiupdate.rst ================================================ KodiUpdate Plugin ================= The ``kodiupdate`` plugin lets you automatically update Kodi_'s music library whenever you change your beets library. To use ``kodiupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll want to configure the specifics of your Kodi host. You can do that using a ``kodi:`` section in your ``config.yaml``, which looks like this: :: kodi: host: localhost port: 8080 user: kodi pwd: kodi To update multiple Kodi instances, specify them as an array: :: kodi: - host: x.x.x.x port: 8080 user: kodi pwd: kodi - host: y.y.y.y port: 8081 user: kodi2 pwd: kodi2 To use the ``kodiupdate`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``kodiupdate`` extra .. code-block:: bash pip install "beets[kodiupdate]" You'll also need to enable JSON-RPC in Kodi. In Kodi's interface, navigate to System/Settings/Network/Services and choose "Allow control of Kodi via HTTP." With that all in place, you'll see beets send the "update" command to your Kodi host every time you change your beets library. .. _kodi: https://kodi.tv/ Configuration ------------- The available options under the ``kodi:`` section are: - **host**: The Kodi host name. Default: ``localhost`` - **port**: The Kodi host port. Default: 8080 - **user**: The Kodi host user. Default: ``kodi`` - **pwd**: The Kodi host password. Default: ``kodi`` ================================================ FILE: docs/plugins/lastgenre.rst ================================================ LastGenre Plugin ================ The ``lastgenre`` plugin fetches *tags* from Last.fm_ and assigns them as genres to your albums and items. .. _last.fm: https://www.last.fm/ Installation ------------ To use the ``lastgenre`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``lastgenre`` extra .. code-block:: bash pip install "beets[lastgenre]" Usage ----- The plugin chooses genres based on a *whitelist*, meaning that only certain tags can be considered genres. This way, tags like "my favorite music" or "seen live" won't be considered genres. The plugin ships with a fairly extensive `internal whitelist`_, but you can set your own in the config file using the ``whitelist`` configuration value or forgo a whitelist altogether by setting the option to ``no``. The genre list file should contain one genre per line. Blank lines are ignored. For the curious, the default genre list is generated by a `script that scrapes Wikipedia`_. .. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt .. _script that scrapes wikipedia: https://gist.github.com/sampsyo/1241307 Canonicalization ~~~~~~~~~~~~~~~~ The plugin can also *canonicalize* genres, meaning that more obscure genres can be turned into coarser-grained ones that are present in the whitelist. This works using a `tree of nested genre names`_, represented using YAML_, where the leaves of the tree represent the most specific genres. The most common way to use this would be with a custom whitelist containing only a desired subset of genres. Consider for a example this minimal whitelist: :: rock heavy metal pop together with the default genre tree. Then an item that has its genre specified as *viking metal* would actually be tagged as *heavy metal* because neither *viking metal* nor its parent *black metal* are in the whitelist. It always tries to use the most specific genre that's available in the whitelist. The relevant subtree path in the default tree looks like this: :: - rock: - heavy metal: - black metal: - viking metal Considering that, it's not very useful to use the default whitelist (which contains about any genre contained in the tree) with canonicalization because nothing would ever be matched to a more generic node since all the specific subgenres are in the whitelist to begin with. .. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml .. _yaml: https://yaml.org/ Genre Source ~~~~~~~~~~~~ When looking up genres for albums or individual tracks, you can choose whether to use Last.fm tags on the album, the artist, or the track. For example, you might want all the albums for a certain artist to carry the same genre. The default is "album". When set to "track", the plugin will fetch *both* album-level and track-level genres for your music when importing albums. Multiple Genres ~~~~~~~~~~~~~~~ By default, the plugin chooses the most popular tag on Last.fm as a genre. If you prefer to use a *list* of popular genre tags, you can increase the number of the ``count`` config option. Lists of up to *count* genres will be stored in the ``genres`` field as a list and written to your media files as separate genre tags. Last.fm_ provides a popularity factor, a.k.a. *weight*, for each tag ranging from 100 for the most popular tag down to 0 for the least popular. The plugin uses this weight to discard unpopular tags. The default is to ignore tags with a weight less then 10. You can change this by setting the ``min_weight`` config option. Specific vs. Popular Genres ~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, the plugin sorts genres by popularity. However, you can use the ``prefer_specific`` option to override this behavior and instead sort genres by specificity, as determined by your whitelist and canonicalization tree. For instance, say you have both ``folk`` and ``americana`` in your whitelist and canonicalization tree and ``americana`` is a leaf within ``folk``. If Last.fm returns both of those tags, lastgenre is going to use the most popular, which is often the most generic (in this case ``folk``). By setting ``prefer_specific`` to true, lastgenre would use ``americana`` instead. Handling pre-populated tags ~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``force``, ``keep_existing`` and ``whitelist`` options control how pre-existing genres are handled. As you would assume, setting ``force: no`` **won't touch pre-existing genre tags** and will only **fetch new genres for empty tags**. When ``force`` is ``yes`` the setting of the ``whitelist`` option (as documented in Usage_) applies to any existing or newly fetched genres. The following configurations are possible: **Setup 1** (default) Add new last.fm genres when **empty**. Any present tags stay **untouched**. .. code-block:: yaml force: no keep_existing: no **Setup 2** **Overwrite all**. Only fresh last.fm genres remain. .. code-block:: yaml force: yes keep_existing: no **Setup 3** **Combine** genres in present tags with new ones (be aware of that with an enabled ``whitelist`` setting, of course some genres might get cleaned up - existing genres take precedence over new ones though. To make sure any existing genres remain, set ``whitelist: no``). .. code-block:: yaml force: yes keep_existing: yes .. attention:: If ``force`` is disabled the ``keep_existing`` option is simply ignored (since ``force: no`` means ``not touching`` existing tags anyway). Configuration ------------- To configure the plugin, make a ``lastgenre:`` section in your configuration file. The available options are: - **auto**: Fetch genres automatically during import. Default: ``yes``. - **canonical**: Use a canonicalization tree. Setting this to ``yes`` will use a built-in tree. You can also set it to a path, like the ``whitelist`` config value, to use your own tree. Default: ``no`` (disabled). - **cleanup_existing**: This option only takes effect with ``force: no``, Setting this to ``yes`` will result in cleanup of existing genres. That includes canonicalization and whitelisting, if enabled. If no matching genre can be determined, the ``fallback`` is used instead. Default: ``no`` (disabled). - **count**: Number of genres to fetch. Default: 1 - **fallback**: A string to use as a fallback genre when no genre is found ``or`` the original genre is not desired to be kept (``keep_existing: no``). You can use the empty string ``''`` to reset the genre. Default: None. - **force**: By default, lastgenre will fetch new genres for empty tags only, enable this option to always try to fetch new last.fm genres. Enable the ``keep_existing`` option to combine existing and new genres. (see `Handling pre-populated tags`_). Default: ``no``. - **keep_existing**: This option alters the ``force`` behavior. If both ``force`` and ``keep_existing`` are enabled, existing genres are combined with new ones. Depending on the ``whitelist`` setting, existing and new genres are filtered accordingly. To ensure only fresh last.fm genres, disable this option. (see `Handling pre-populated tags`_) Default: ``no``. - **min_weight**: Minimum popularity factor below which genres are discarded. Default: 10. - **prefer_specific**: Sort genres by the most to least specific, rather than most to least popular. Note that this option requires a ``canonical`` tree, and if not configured it will automatically enable and use the built-in tree. Default: ``no``. - **source**: Which entity to look up in Last.fm. Can be either ``artist``, ``album`` or ``track``. Default: ``album``. - **whitelist**: The filename of a custom genre list, ``yes`` to use the internal whitelist, or ``no`` to consider all genres valid. Default: ``yes``. - **title_case**: Convert the new tags to TitleCase before saving. Default: ``yes``. Running Manually ---------------- In addition to running automatically on import, the plugin can also be run manually from the command line. Use the command ``beet lastgenre [QUERY]`` to fetch genres for albums or items matching a certain query. By default, ``beet lastgenre`` matches albums. To match individual tracks or singletons, use the ``-A`` switch: ``beet lastgenre -A [QUERY]``. To preview the changes that would be made without applying them, use the ``-p`` or ``--pretend`` flag. This shows which genres would be set but does not write or store any changes. To disable automatic genre fetching on import, set the ``auto`` config option to false. Tuning Logs ----------- To enable tuning logs, run ``beet -vvv lastgenre ...`` or ``beet -vvv import ...``. This enables additional messages at the ``DEBUG`` log level, showing for example what data was received from last.fm at each stage of genre fetching (artist, album, and track levels) before any canonicalization or whitelist filtering is applied. Tuning logs are useful for adjusting the plugin’s settings and understanding its behavior, though they can be quite verbose. ================================================ FILE: docs/plugins/lastimport.rst ================================================ LastImport Plugin ================= The ``lastimport`` plugin downloads play-count data from your Last.fm_ library into beets' database. You can later create :doc:`smart playlists </plugins/smartplaylist>` by querying ``lastfm_play_count`` and do other fun stuff with this field. .. _last.fm: https://www.last.fm/ Installation ------------ To use the ``lastimport`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``lastimport`` extra .. code-block:: bash pip install "beets[lastimport]" Next, add your Last.fm username to your beets configuration file: :: lastfm: user: beetsfanatic Importing Play Counts --------------------- Simply run ``beet lastimport`` and wait for the plugin to request tracks from Last.fm and match them to your beets library. (You will be notified of tracks in your Last.fm profile that do not match any songs in your library.) Then, your matched tracks will be populated with the ``lastfm_play_count`` field, which you can use in any query or template. For example: :: $ beet ls -f '$title: $lastfm_play_count' lastfm_play_count:5.. Eple (Melody A.M.): 60 To see more information (namely, the specific play counts for matched tracks), use the ``-v`` option. .. versionchanged:: 2.8.0 The ``play_count`` field was renamed to ``lastfm_play_count`` to avoid confusion with ``play_count`` field populated by :doc:`mpdstats` plugin. Configuration ------------- Aside from the required ``lastfm.user`` field, this plugin has some specific options under the ``lastimport:`` section: - **per_page**: The number of tracks to request from the API at once. Default: 500. - **retry_limit**: How many times should we re-send requests to Last.fm on failure? Default: 3. By default, the plugin will use beets's own Last.fm API key. You can also override it with your own API key: :: lastfm: api_key: your_api_key ================================================ FILE: docs/plugins/limit.rst ================================================ Limit Query Plugin ================== ``limit`` is a plugin to limit a query to the first or last set of results. We also provide a query prefix ``'<n'`` to inline the same behavior in the ``list`` command. They are analogous to piping results: $ beet [list|ls] [QUERY] | [head|tail] -n n There are two provided interfaces: 1. ``beet lslimit [--head n | --tail n] [QUERY]`` returns the head or tail of a query 2. ``beet [list|ls] [QUERY] '<n'`` returns the head of a query There are two differences in behavior: 1. The query prefix does not support tail. 2. The query prefix could appear anywhere in the query but will only have the same behavior as the ``lslimit`` command and piping to ``head`` when it appears last. Performance for the query previx is much worse due to the current singleton-based implementation. So why does the query prefix exist? Because it composes with any other query-based API or plugin (see :doc:`/reference/query`). For example, you can use the query prefix in ``smartplaylist`` (see :doc:`/plugins/smartplaylist`) to limit the number of tracks in a smart playlist for applications like most played and recently added. Configuration ------------- Enable the ``limit`` plugin in your configuration (see :ref:`using-plugins`). Examples -------- First 10 tracks .. code-block:: sh $ beet ls | head -n 10 $ beet lslimit --head 10 $ beet ls '<10' Last 10 tracks .. code-block:: sh $ beet ls | tail -n 10 $ beet lslimit --tail 10 100 mostly recently released tracks .. code-block:: sh $ beet lslimit --head 100 year- month- day- $ beet ls year- month- day- '<100' $ beet lslimit --tail 100 year+ month+ day+ ================================================ FILE: docs/plugins/listenbrainz.rst ================================================ .. _listenbrainz: ListenBrainz Plugin =================== The ListenBrainz plugin for beets allows you to interact with the ListenBrainz service. Configuration ------------- To enable the ListenBrainz plugin, add the following to your beets configuration file (config.yaml_): .. code-block:: yaml plugins: - listenbrainz You can then configure the plugin by providing your Listenbrainz token (see intructions here_) and username: :: listenbrainz: token: TOKEN username: LISTENBRAINZ_USERNAME Usage ----- Once the plugin is enabled, you can import the listening history using the ``lbimport`` command in beets. .. _config.yaml: ../reference/config.rst .. _here: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#get-the-user-token ================================================ FILE: docs/plugins/loadext.rst ================================================ Load Extension Plugin ===================== Beets uses an SQLite database to store and query library information, which has support for extensions to extend its functionality. The ``loadext`` plugin lets you enable these SQLite extensions within beets. One of the primary uses of this within beets is with the `"ICU" extension`_, which adds support for case insensitive querying of non-ASCII characters. .. _"icu" extension: https://www.sqlite.org/src/dir?ci=7461d2e120f21493&name=ext/icu Configuration ------------- To configure the plugin, make a ``loadext`` section in your configuration file. The section must consist of a list of paths to extensions to load, which looks like this: .. code-block:: yaml loadext: - libicu If a relative path is specified, it is resolved relative to the beets configuration directory. If no file extension is specified, the default dynamic library extension for the current platform will be used. Building the ICU extension -------------------------- This section is for **advanced** users only, and is not an in-depth guide on building the extension. To compile the ICU extension, you will need a few dependencies: - gcc - icu-devtools - libicu - libicu-dev - libsqlite3-dev Here's roughly how to download, build and install the extension (although the specifics may vary from system to system): .. code-block:: shell $ wget https://sqlite.org/2019/sqlite-src-3280000.zip $ unzip sqlite-src-3280000.zip $ cd sqlite-src-3280000/ext/icu $ gcc -shared -fPIC icu.c $(icu-config --ldflags) -o libicu.so $ cp libicu.so ~/.config/beets ================================================ FILE: docs/plugins/lyrics.rst ================================================ Lyrics Plugin ============= The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web. Namely, the current version of the plugin uses Genius.com_, Tekstowo.pl_, LRCLIB_ and, optionally, the Google Custom Search API. .. _genius.com: https://genius.com/ .. _lrclib: https://lrclib.net/ .. _tekstowo.pl: https://www.tekstowo.pl/ Install ------- Firstly, enable ``lyrics`` plugin in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``lyrics`` extra .. code-block:: bash pip install "beets[lyrics]" Fetch Lyrics During Import -------------------------- When importing new files, beets will now fetch lyrics for files that don't already have them. The lyrics will be stored in the beets database. The plugin also sets a few useful flexible attributes: - ``lyrics_backend``: name of the backend that provided the lyrics - ``lyrics_url``: URL of the page where the lyrics were found - ``lyrics_language``: original language of the lyrics - ``lyrics_translation_language``: language of the lyrics translation (if translation is enabled) If the ``import.write`` config option is on, then the lyrics will also be written to the files' tags. Configuration ------------- To configure the plugin, make a ``lyrics:`` section in your configuration file. Default configuration: .. code-block:: yaml lyrics: auto: yes translate: api_key: from_languages: [] to_language: dist_thresh: 0.11 fallback: null force: no google_API_key: null google_engine_ID: 009217259823014548361:lndtuqkycfu print: no sources: [lrclib, google, genius] synced: no The available options are: - **auto**: Fetch lyrics automatically during import. - **translate**: - **api_key**: Api key to access your Azure Translator resource. (see :ref:`lyrics-translation`) - **from_languages**: By default all lyrics with a language other than ``translate_to`` are translated. Use a list of language codes to restrict them. - **to_language**: Language code to translate lyrics to. - **dist_thresh**: The maximum distance between the artist and title combination of the music file and lyrics candidate to consider them a match. Lower values will make the plugin more strict, higher values will make it more lenient. This does not apply to the ``lrclib`` backend as it matches durations. - **fallback**: By default, the file will be left unchanged when no lyrics are found. Use the empty string ``''`` to reset the lyrics in such a case. - **force**: By default, beets won't fetch lyrics if the files already have ones. To instead always fetch lyrics, set the ``force`` option to ``yes``. - **google_API_key**: Your Google API key (to enable the Google Custom Search backend). - **google_engine_ID**: The custom search engine to use. Default: The `beets custom search engine`_, which gathers an updated list of sources known to be scrapeable. - **print**: Print lyrics to the console. - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. The ``google`` source will be automatically deactivated if no ``google_API_key`` is setup. By default, ``musixmatch`` and ``tekstowo`` are excluded because they block the beets User-Agent. - **synced**: Prefer synced lyrics over plain lyrics if a source offers them. Currently ``lrclib`` is the only source that provides them. Using this option, existing synced lyrics are not replaced by newly fetched plain lyrics (even when ``force`` is enabled). To allow that replacement, disable ``synced``. .. _beets custom search engine: https://cse.google.com/cse?cx=009217259823014548361:lndtuqkycfu Fetching Lyrics Manually ------------------------ The ``lyrics`` command provided by this plugin fetches lyrics for items that match a query (see :doc:`/reference/query`). For example, ``beet lyrics magnetic fields absolutely cuckoo`` will get the lyrics for the appropriate Magnetic Fields song, ``beet lyrics magnetic fields`` will get lyrics for all my tracks by that band, and ``beet lyrics`` will get lyrics for my entire library. The lyrics will be added to the beets database and, if ``import.write`` is on, embedded into files' metadata. The ``-p, --print`` option to the ``lyrics`` command makes it print lyrics out to the console so you can view the fetched (or previously-stored) lyrics. The ``-f, --force`` option forces the command to fetch lyrics, even for tracks that already have lyrics. Inversely, the ``-l, --local`` option restricts operations to lyrics that are locally available, which show lyrics faster without using the network at all. Rendering Lyrics into Other Formats ----------------------------------- The ``-r directory, --write-rest directory`` option renders all lyrics as reStructuredText_ (ReST) documents in ``directory``. That directory, in turn, can be parsed by tools like Sphinx_ to generate HTML, ePUB, or PDF documents. Minimal ``conf.py`` and ``index.rst`` files are created the first time the command is run. They are not overwritten on subsequent runs, so you can safely modify these files to customize the output. Sphinx supports various builders_, see a few suggestions: .. admonition:: Build an HTML version :: sphinx-build -b html <dir> <dir>/html .. admonition:: Build an ePUB3 formatted file, usable on ebook readers :: sphinx-build -b epub3 <dir> <dir>/epub .. admonition:: Build a PDF file, which incidentally also builds a LaTeX file :: sphinx-build -b latex <dir> <dir>/latex && make -C <dir>/latex all-pdf .. _builders: https://www.sphinx-doc.org/en/master/usage/builders/index.html .. _restructuredtext: https://sourceforge.net/projects/docutils/ .. _sphinx: https://www.sphinx-doc.org/en/master/ Activate Google Custom Search ----------------------------- You need to `register for a Google API key <https://console.developers.google.com/>`__. Set the ``google_API_key`` configuration option to your key. Then add ``google`` to the list of sources in your configuration (or use default list, which includes it as long as you have an API key). If you use default ``google_engine_ID``, we recommend limiting the sources to ``google`` as the other sources are already included in the Google results. Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine_ID`` configuration option. By default, beets use a list of sources known to be scrapeable. Note that the Google custom search API is limited to 100 queries per day. After that, the lyrics plugin will fall back on other declared data sources. .. _define a custom search engine: https://programmablesearchengine.google.com/about/ .. _lyrics-translation: Activate On-the-Fly Translation ------------------------------- We use Azure to optionally translate your lyrics. To set up the integration, follow these steps: 1. `Create a Translator resource`_ on Azure. Make sure the region of the translator resource is set to Global. You will get 401 unauthorized errors if not. The region of the resource group does not matter. 2. `Obtain its API key`_. 3. Add the API key to your configuration as ``translate.api_key``. 4. Configure your target language using the ``translate.to_language`` option. For example, with the following configuration .. code-block:: yaml lyrics: translate: api_key: YOUR_TRANSLATOR_API_KEY to_language: de You should expect lyrics like this: :: Original verse / Ursprünglicher Vers Some other verse / Ein anderer Vers .. _create a translator resource: https://learn.microsoft.com/en-us/azure/ai-services/translator/create-translator-resource .. _obtain its api key: https://learn.microsoft.com/en-us/python/api/overview/azure/ai-translation-text-readme?view=azure-python&preserve-view=true#get-an-api-key ================================================ FILE: docs/plugins/mbcollection.rst ================================================ MusicBrainz Collection Plugin ============================= The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to maintain your `music collection`_ list there. .. _music collection: https://musicbrainz.org/doc/Collections To begin, just enable the ``mbcollection`` plugin in your configuration (see :ref:`using-plugins`). Then, add your MusicBrainz username and password to your :doc:`configuration file </reference/config>` under a ``musicbrainz`` section: :: musicbrainz: user: you pass: seekrit Then, use the ``beet mbupdate`` command to send your albums to MusicBrainz. The command automatically adds all of your albums to the first collection it finds. If you don't have a MusicBrainz collection yet, you may need to add one to your profile first. The command has one command-line option: - To remove albums from the collection which are no longer present in the beets database, use the ``-r`` (``--remove``) flag. Configuration ------------- To configure the plugin, make a ``mbcollection:`` section in your configuration file. There is one option available: - **auto**: Automatically amend your MusicBrainz collection whenever you import a new album. Default: ``no``. - **collection**: The MBID of which MusicBrainz collection to update. Default: ``None``. - **remove**: Remove albums from collections which are no longer present in the beets database. Default: ``no``. ================================================ FILE: docs/plugins/mbpseudo.rst ================================================ MusicBrainz Pseudo-Release Plugin ================================= The ``mbpseudo`` plugin can be used *instead of* the ``musicbrainz`` plugin to search for MusicBrainz pseudo-releases_ during the import process, which are added to the normal candidates from the MusicBrainz search. .. _pseudo-releases: https://musicbrainz.org/doc/Style/Specific_types_of_releases/Pseudo-Releases This is useful for releases whose title and track titles are written with a script_ that can be translated or transliterated into a different one. .. _script: https://en.wikipedia.org/wiki/ISO_15924 Pseudo-releases will only be included if the initial search in MusicBrainz returns releases whose script is *not* desired and whose relationships include pseudo-releases with desired scripts. Configuration ------------- Since this plugin first searches for official releases from MusicBrainz, all options from the ``musicbrainz`` plugin's :ref:`musicbrainz-config` are supported, but they must be specified under ``mbpseudo`` in the configuration file. Additionally, the configuration expects an array of scripts that are desired for the pseudo-releases. For ``artist`` in particular, keep in mind that even pseudo-releases might specify it with the original script, so you should also configure import :ref:`languages` to give artist aliases more priority. Therefore, the minimum configuration for this plugin looks like this: .. code-block:: yaml plugins: mbpseudo # remove musicbrainz import: languages: en mbpseudo: scripts: - Latn Note that the ``search_limit`` configuration applies to the initial search for official releases, and that the ``data_source`` in the database will be "MusicBrainz". Nevertheless, ``data_source_mismatch_penalty`` must also be specified under ``mbpseudo`` if desired (see also :ref:`metadata-source-plugin-configuration`). An example with multiple data sources may look like this: .. code-block:: yaml plugins: mbpseudo deezer import: languages: en mbpseudo: data_source_mismatch_penalty: 0 scripts: - Latn deezer: data_source_mismatch_penalty: 0.2 By default, the data from the pseudo-release will be used to create a proposal that is independent from the official release and sets all properties in its metadata. It's possible to change the configuration so that some information from the pseudo-release is instead added as custom tags, keeping the metadata from the official release: .. code-block:: yaml mbpseudo: # other config not shown custom_tags_only: yes The default custom tags with this configuration are specified as mappings where the keys define the tag names and the values define the pseudo-release property that will be used to set the tag's value: .. code-block:: yaml mbpseudo: album_custom_tags: album_transl: album album_artist_transl: artist track_custom_tags: title_transl: title artist_transl: artist Note that the information for each set of custom tags corresponds to different metadata levels (album or track level), which is why ``artist`` appears twice even though it effectively references album artist and track artist respectively. If you want to modify any mapping under ``album_custom_tags`` or ``track_custom_tags``, you must specify *everything* for that set of tags in your configuration file because any customization replaces the whole dictionary of mappings for that level. .. note:: These custom tags are also added to the music files, not only to the database. ================================================ FILE: docs/plugins/mbsubmit.rst ================================================ MusicBrainz Submit Plugin ========================= The ``mbsubmit`` plugin provides extra prompt choices when an import session fails to find a good enough match for a release. Additionally, it provides an ``mbsubmit`` command that prints the tracks of the current album in a format that is parseable by MusicBrainz's `track parser`_. The prompt choices are: - Print the tracks to stdout in a format suitable for MusicBrainz's `track parser`_. - Open the program Picard_ with the unmatched folder as an input, allowing you to start submitting the unmatched release to MusicBrainz with many input fields already filled in, thanks to Picard reading the preexisting tags of the files. For the last option, Picard_ is assumed to be installed and available on the machine including a ``picard`` executable. Picard developers list `download options`_. `other GNU/Linux distributions`_ may distribute Picard via their package manager as well. .. _download options: https://picard.musicbrainz.org/downloads/ .. _other gnu/linux distributions: https://repology.org/project/picard-tagger/versions .. _picard: https://picard.musicbrainz.org/ .. _track parser: https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings Usage ----- Enable the ``mbsubmit`` plugin in your configuration (see :ref:`using-plugins`) and select one of the options mentioned above. Here the option ``Print tracks`` choice is demonstrated: :: No matching release found for 3 tracks. For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, Print tracks, Open files with Picard? p 01. An Obscure Track - An Obscure Artist (3:37) 02. Another Obscure Track - An Obscure Artist (2:05) 03. The Third Track - Another Obscure Artist (3:02) No matching release found for 3 tracks. For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, Print tracks? You can also run ``beet mbsubmit QUERY`` to print the track information for any album: :: $ beet mbsubmit album:"An Obscure Album" 01. An Obscure Track - An Obscure Artist (3:37) 02. Another Obscure Track - An Obscure Artist (2:05) 03. The Third Track - Another Obscure Artist (3:02) As MusicBrainz currently does not support submitting albums programmatically, the recommended workflow is to copy the output of the ``Print tracks`` choice and paste it into the parser that can be found by clicking on the "Track Parser" button on MusicBrainz "Tracklist" tab. Configuration ------------- To configure the plugin, make a ``mbsubmit:`` section in your configuration file. The following options are available: - **format**: The format used for printing the tracks, defined using the same template syntax as beets’ :doc:`path formats </reference/pathformat>`. Default: ``$track. $title - $artist ($length)``. - **threshold**: The minimum strength of the autotagger recommendation that will cause the ``Print tracks`` choice to be displayed on the prompt. Default: ``medium`` (causing the choice to be displayed for all albums that have a recommendation of medium strength or lower). Valid values: ``none``, ``low``, ``medium``, ``strong``. - **picard_path**: The path to the ``picard`` executable. Could be an absolute path, and if not, ``$PATH`` is consulted. The default value is simply ``picard``. Windows users will have to find and specify the absolute path to their ``picard.exe``. That would probably be: ``C:\Program Files\MusicBrainz Picard\picard.exe``. Please note that some values of the ``threshold`` configuration option might require other ``beets`` command line switches to be enabled in order to work as intended. In particular, setting a threshold of ``strong`` will only display the prompt if ``timid`` mode is enabled. You can find more information about how the recommendation system works at :ref:`match-config`. ================================================ FILE: docs/plugins/mbsync.rst ================================================ MBSync Plugin ============= This plugin provides the ``mbsync`` command, which lets you synchronize metadata for albums and tracks that have external data source IDs. This is useful for syncing your library with online data or when changing configuration options that affect tag writing. If your music library already contains correct tags, you can speed up the initial import by importing files "as-is" and then using ``mbsync`` to write tags according to your beets configuration. Usage ----- Enable the ``mbsync`` plugin in your configuration (see :ref:`using-plugins`) and then run ``beet mbsync QUERY`` to fetch updated metadata for a part of your collection (or omit the query to run over your whole library). ID lookups use each item's stored ``data_source``. If a row has no ``data_source``, ``mbsync`` falls back to ``MusicBrainz``. This plugin treats albums and singletons (non-album tracks) separately. It first processes all matching singletons and then proceeds on to full albums. The same query is used to search for both kinds of entities. The command has a few command-line options: - To preview the changes that would be made without applying them, use the ``-p`` (``--pretend``) flag. - By default, files will be moved (renamed) according to their metadata if they are inside your beets library directory. To disable this, use the ``-M`` (``--nomove``) command-line option. - If you have the ``import.write`` configuration option enabled, then this plugin will write new metadata to files' tags. To disable this, use the ``-W`` (``--nowrite``) option. - To customize the output of unrecognized items, use the ``-f`` (``--format``) option. The default output is ``format_item`` or ``format_album`` for items and albums, respectively. ================================================ FILE: docs/plugins/metasync.rst ================================================ MetaSync Plugin =============== This plugin provides the ``metasync`` command, which lets you fetch certain metadata from other sources: for example, your favorite audio player. Currently, the plugin supports synchronizing with the Amarok_ music player, and with iTunes_. It can fetch the rating, score, first-played date, last-played date, play count, and track uid from Amarok. .. _amarok: https://amarok.kde.org/ .. _itunes: https://www.apple.com/itunes/ Installation ------------ Enable the ``metasync`` plugin in your configuration (see :ref:`using-plugins`). To synchronize with Amarok, you'll need the dbus-python_ library. In such case, install ``beets`` with ``metasync`` extra .. code-block:: bash pip install "beets[metasync]" .. _dbus-python: https://dbus.freedesktop.org/releases/dbus-python/ Configuration ------------- To configure the plugin, make a ``metasync:`` section in your configuration file. The available options are: - **source**: A list of comma-separated sources to fetch metadata from. Set this to "amarok" or "itunes" to enable synchronization with that player. Default: empty The follow subsections describe additional configure required for some players. itunes ~~~~~~ The path to your iTunes library **xml** file has to be configured, e.g.: :: metasync: source: itunes itunes: library: ~/Music/iTunes Library.xml Please note the indentation. Usage ----- Run ``beet metasync QUERY`` to fetch metadata from the configured list of sources. The command has a few command-line options: - To preview the changes that would be made without applying them, use the ``-p`` (``--pretend``) flag. - To specify temporary sources to fetch metadata from, use the ``-s`` (``--source``) flag with a comma-separated list of a sources. ================================================ FILE: docs/plugins/missing.rst ================================================ Missing Plugin ============== This plugin adds a new command, ``missing`` or ``miss``, which finds and lists missing tracks for albums in your collection. Each album requires one network call to album data source. Usage ----- The ``beet missing`` command fetches album information from the origin data source and lists names of the **tracks** that are missing from your library. Track-level checks use the album's stored ``data_source`` and fall back to ``MusicBrainz`` when no source is stored. It can also list the names of missing **albums** for each artist, although this is limited to albums from the MusicBrainz data source only. You can customize the output format, show missing counts instead of track titles, or display the total number of missing entities across your entire library: :: -f FORMAT, --format=FORMAT print with custom FORMAT -c, --count count missing tracks per album -t, --total count totals across the entire library -a, --album show missing albums for artist instead of tracks for album --release-type show only missing albums of specified release type. You can provide this argument multiple times to specify multiple release types to filter to. If not provided, defaults to just the "album" release type. provided, it uses the configured ``missing.release_type`` (default: "album"). …or by editing the corresponding configuration options. .. warning:: Option ``-c`` is ignored when used with ``-a``, and ``--release-type`` is ignored when not used with ``-a``. Valid release types can be shown by running ``beet missing -h``. Configuration ------------- To configure the plugin, make a ``missing:`` section in your configuration file. The available options are: - **count**: Print a count of missing tracks per album, with the global ``format_album`` used for formatting. Default: ``no``. - **total**: Print a single count of missing tracks in all albums. Default: ``no``. Formatting ~~~~~~~~~~ - This plugin uses global formatting options from the main configuration; see :ref:`format_item` and :ref:`format_album`: - :ref:`format_item`: Used when listing missing tracks (default item format). - :ref:`format_album`: Used when showing counts (``-c``) or missing albums (``-a``). Here's an example :: format_album: $albumartist - $album format_item: $artist - $album - $title missing: count: no total: no Template Fields --------------- With this plugin enabled, the ``$missing`` template field expands to the number of tracks missing from each album. Examples -------- List all missing tracks in your collection: :: beet missing List all missing albums in your collection: :: beet missing -a List all missing tracks from 2008: :: beet missing year:2008 Print out a unicode histogram of the missing track years using spark_: :: beet missing -f '$year' | spark ▆▁▆█▄▇▇▄▇▇▁█▇▆▇▂▄█▁██▂█▁▁██▁█▂▇▆▂▇█▇▇█▆▆▇█▇█▇▆██▂▇ Print out a listing of all albums with missing tracks, and respective counts: :: beet missing -c Print out a count of the total number of missing tracks: :: beet missing -t List all missing albums of release type "compilation" in your collection: :: beet missing -a --release-type compilation List all missing albums of release type "compilation" and album in your collection: :: beet missing -a --release-type compilation --release-type album Call this plugin from other beet commands: :: beet ls -a -f '$albumartist - $album: $missing' .. _spark: https://github.com/holman/spark ================================================ FILE: docs/plugins/mpdstats.rst ================================================ MPDStats Plugin =============== ``mpdstats`` is a plugin for beets that collects statistics about your listening habits from MPD_. It collects the following information about tracks: - ``play_count``: The number of times you *fully* listened to this track. - ``skip_count``: The number of times you *skipped* this track. - ``last_played``: UNIX timestamp when you last played this track. - ``rating``: A rating based on ``play_count`` and ``skip_count``. To gather these statistics it runs as an MPD client and watches the current state of MPD. This means that ``mpdstats`` needs to be running continuously for it to work. .. _mpd: https://www.musicpd.org/ Installing Dependencies ----------------------- This plugin requires the python-mpd2 library in order to talk to the MPD server. To use the ``mpdstats`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``mpdstats`` extra pip install "beets[mpdstats]" Usage ----- Use the ``mpdstats`` command to fire it up: :: $ beet mpdstats Configuration ------------- To configure the plugin, make an ``mpd:`` section in your configuration file. The available options are: - **host**: The MPD server hostname. Default: The ``$MPD_HOST`` environment variable if set, falling back to ``localhost`` otherwise. - **port**: The MPD server port. Default: The ``$MPD_PORT`` environment variable if set, falling back to 6600 otherwise. - **password**: The MPD server password. Default: None. - **music_directory**: If your MPD library is at a different location from the beets library (e.g., because one is mounted on a NFS share), specify the path here. - **strip_path**: If your MPD library contains local path, specify the part to remove here. Combining this with **music_directory** you can mangle MPD path to match the beets library one. Default: The beets library directory. - **rating**: Enable rating updates. Default: ``yes``. - **rating_mix**: Tune the way rating is calculated (see below). Default: 0.75. - **played_ratio_threshold**: If a song was played for less than this percentage of its duration it will be considered a skip. Default: 0.85 A Word on Ratings ----------------- Ratings are calculated based on the *play_count*, *skip_count* and the last *action* (play or skip). It consists in one part of a *stable_rating* and in another part on a *rolling_rating*. The *stable_rating* is calculated like this: :: stable_rating = (play_count + 1.0) / (play_count + skip_count + 2.0) So if the *play_count* equals the *skip_count*, the *stable_rating* is always 0.5. More *play_counts* adjust the rating up to 1.0. More *skip_counts* adjust it down to 0.0. One of the disadvantages of this rating system, is that it doesn't really cover *recent developments*. e.g. a song that you loved last year and played over 50 times will keep a high rating even if you skipped it the last 10 times. That's were the *rolling_rating* comes in. If a song has been fully played, the *rolling_rating* is calculated like this: :: rolling_rating = old_rating + (1.0 - old_rating) / 2.0 If a song has been skipped, like this: :: rolling_rating = old_rating - old_rating / 2.0 So *rolling_rating* adapts pretty fast to *recent developments*. But it's too fast. Taking the example from above, your old favorite with 50 plays will get a negative rating (<0.5) the first time you skip it. Also not good. To take the best of both worlds, we mix the ratings together with the ``rating_mix`` factor. A ``rating_mix`` of 0.0 means all *rolling* and 1.0 means all *stable*. We found 0.75 to be a good compromise, but fell free to play with that. Warning ------- This has only been tested with MPD versions >= 0.16. It may not work on older versions. If that is the case, please report an issue_. .. _issue: https://github.com/beetbox/beets/issues ================================================ FILE: docs/plugins/mpdupdate.rst ================================================ MPDUpdate Plugin ================ ``mpdupdate`` is a very simple plugin for beets that lets you automatically update MPD_'s index whenever you change your beets library. .. _mpd: https://www.musicpd.org/ To use ``mpdupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your MPD server. You can do that using an ``mpd:`` section in your ``config.yaml``, which looks like this: :: mpd: host: localhost port: 6600 password: seekrit With that all in place, you'll see beets send the "update" command to your MPD server every time you change your beets library. If you want to communicate with MPD over a Unix domain socket instead over TCP, just give the path to the socket in the filesystem for the ``host`` setting. (Any ``host`` value starting with a slash or a tilde is interpreted as a domain socket.) Configuration ------------- The available options under the ``mpd:`` section are: - **host**: The MPD server name. Default: The ``$MPD_HOST`` environment variable if set, falling back to ``localhost`` otherwise. - **port**: The MPD server port. Default: The ``$MPD_PORT`` environment variable if set, falling back to 6600 otherwise. - **password**: The MPD server password. Default: None. ================================================ FILE: docs/plugins/musicbrainz.rst ================================================ MusicBrainz Plugin ================== The ``musicbrainz`` plugin extends the autotagger's search capabilities to include matches from the MusicBrainz_ database. .. _musicbrainz: https://musicbrainz.org/ Installation ------------ To use the ``musicbrainz`` plugin, enable it in your configuration (see :ref:`using-plugins`) .. _musicbrainz-config: Configuration ------------- This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. Default ~~~~~~~ .. code-block:: yaml musicbrainz: host: musicbrainz.org https: no ratelimit: 1 ratelimit_interval: 1.0 extra_tags: [] genres: no genres_tag: genre external_ids: discogs: no bandcamp: no spotify: no deezer: no beatport: no tidal: no data_source_mismatch_penalty: 0.5 search_limit: 5 .. conf:: host :default: musicbrainz.org The Web server hostname (and port, optionally) that will be contacted by beets. You can use this to configure beets to use `your own MusicBrainz database <https://musicbrainz.org/doc/MusicBrainz_Server/Setup>`__ instead of the `main server`_. The server must have search indices enabled (see `Building search indexes`_). Example: .. code-block:: yaml musicbrainz: host: localhost:5000 .. conf:: https :default: no Makes the client use HTTPS instead of HTTP. This setting applies only to custom servers. The official MusicBrainz server always uses HTTPS. .. conf:: ratelimit :default: 1 Controls the number of Web service requests per second. This setting applies only to custom servers. The official MusicBrainz server enforces a rate limit of 1 request per second. .. conf:: ratelimit_interval :default: 1.0 The time interval (in seconds) for the rate limit. Only applies to custom servers. .. conf:: enabled :default: yes .. deprecated:: 2.4 Add ``musicbrainz`` to the ``plugins`` list instead. .. conf:: extra_tags :default: [] By default, beets will use only the artist, album, and track count to query MusicBrainz. Additional tags to be queried can be supplied with the ``extra_tags`` setting. This setting should improve the autotagger results if the metadata with the given tags match the metadata returned by MusicBrainz. Tags supported by this setting: * ``alias`` (also search for release aliases matching the query) * ``barcode`` * ``catalognum`` * ``country`` * ``label`` * ``media`` * ``tracks`` (number of tracks on the release) * ``year`` Example: .. code-block:: yaml musicbrainz: extra_tags: [alias, barcode, catalognum, country, label, media, tracks, year] .. conf:: genres :default: no Use MusicBrainz genre tags to populate (and replace if it's already set) the ``genre`` tag. This will make it a list of all the genres tagged for the release and the release-group on MusicBrainz, separated by "; " and sorted by the total number of votes. .. conf:: external_ids **Default** .. code-block:: yaml musicbrainz: external_ids: discogs: no spotify: no bandcamp: no beatport: no deezer: no tidal: no Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz importer to look for links to related metadata sources. If such a link is available the release ID will be extracted from the URL provided and imported to the beets library. The library fields of the corresponding :ref:`autotagger_extensions` are used to save the data as flexible attributes (``discogs_album_id``, ``bandcamp_album_id``, ``spotify_album_id``, ``beatport_album_id``, ``deezer_album_id``, ``tidal_album_id``). On re-imports existing data will be overwritten. .. conf:: genres_tag :default: genre Either ``genre`` or ``tag``. Specify ``genre`` to use just musicbrainz genre and ``tag`` to use all user-supplied musicbrainz tags. .. include:: ./shared_metadata_source_config.rst .. _building search indexes: https://wiki.musicbrainz.org/History:Development/Search_server_setup .. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting .. _main server: https://musicbrainz.org/ ================================================ FILE: docs/plugins/parentwork.rst ================================================ ParentWork Plugin ================= The ``parentwork`` plugin fetches the work title, parent work title and parent work composer from MusicBrainz. In the MusicBrainz database, a recording can be associated with a work. A work can itself be associated with another work, for example one being part of the other (what we call the *direct parent*). This plugin looks the work id from the library and then looks up the direct parent, then the direct parent of the direct parent and so on until it reaches the top. The work at the top is what we call the *parent work*. This plugin is especially designed for classical music. For classical music, just fetching the work title as in MusicBrainz is not satisfying, because MusicBrainz has separate works for, for example, all the movements of a symphony. This plugin aims to solve this problem by also fetching the parent work, which would be the whole symphony in this example. The plugin can detect changes in ``mb_workid`` so it knows when to re-fetch other metadata, such as ``parentwork``. To do this, when it runs, it stores a copy of ``mb_workid`` in the bookkeeping field ``parentwork_workid_current``. At any later run of ``beet parentwork`` it will check if the tags ``mb_workid`` and ``parentwork_workid_current`` are still identical. If it is not the case, it means the work has changed and all the tags need to be fetched again. This plugin adds seven tags: - **parentwork**: The title of the parent work. - **mb_parentworkid**: The MusicBrainz id of the parent work. - **parentwork_disambig**: The disambiguation of the parent work title. - **parent_composer**: The composer of the parent work. - **parent_composer_sort**: The sort name of the parent work composer. - **work_date**: The composition date of the work, or the first parent work that has a composition date. Format: yyyy-mm-dd. - **parentwork_workid_current**: The MusicBrainz id of the work as it was when the parentwork was retrieved. This tag exists only for internal bookkeeping, to keep track of recordings whose works have changed. - **parentwork_date**: The composition date of the parent work. Configuration ------------- To configure the plugin, make a ``parentwork:`` section in your configuration file. The available options are: - **force**: As a default, ``parentwork`` only fetches work info for recordings that do not already have a ``parentwork`` tag or where ``mb_workid`` differs from ``parentwork_workid_current``. If ``force`` is enabled, it fetches it for all recordings. Default: ``no`` - **auto**: If enabled, automatically fetches works at import. It takes quite some time, because beets is restricted to one MusicBrainz query per second. Default: ``no`` ================================================ FILE: docs/plugins/permissions.rst ================================================ Permissions Plugin ================== The ``permissions`` plugin allows you to set file permissions for imported music files and its directories. To use the ``permissions`` plugin, enable it in your configuration (see :ref:`using-plugins`). Permissions will be adjusted automatically on import. Configuration ------------- To configure the plugin, make an ``permissions:`` section in your configuration file. The ``file`` config value therein uses **octal modes** to specify the desired permissions. The default flags for files are octal 644 and 755 for directories. Here's an example: :: permissions: file: 644 dir: 755 ================================================ FILE: docs/plugins/play.rst ================================================ Play Plugin =========== The ``play`` plugin allows you to pass the results of a query to a music player in the form of an m3u playlist or paths on the command line. Command Line Usage ------------------ To use the ``play`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then use it by invoking the ``beet play`` command with a query. The command will create a temporary m3u file and open it using an appropriate application. You can query albums instead of tracks using the ``-a`` option. By default, the playlist is opened using the ``open`` command on OS X, ``xdg-open`` on other Unixes, and ``start`` on Windows. To configure the command, you can use a ``play:`` section in your configuration file: :: play: command: /Applications/VLC.app/Contents/MacOS/VLC You can also specify additional space-separated options to command (like you would on the command-line): :: play: command: /usr/bin/command --option1 --option2 some_other_option While playing you'll be able to interact with the player if it is a command-line oriented, and you'll get its output in real time. Interactive Usage ----------------- The ``play`` plugin can also be invoked during an import. If enabled, the plugin adds a ``plaY`` option to the prompt, so pressing ``y`` will execute the configured command and play the items currently being imported. Once the configured command exits, you will be returned to the import decision prompt. If your player is configured to run in the background (in a client/server setup), the music will play until you choose to stop it, and the import operation continues immediately. Configuration ------------- To configure the plugin, make a ``play:`` section in your configuration file. The available options are: - **command**: The command used to open the playlist. Default: ``open`` on OS X, ``xdg-open`` on other Unixes and ``start`` on Windows. Insert ``$args`` to use the ``--args`` feature. - **relative_to**: If set, emit paths relative to this directory. Default: None. - **use_folders**: When using the ``-a`` option, the m3u will contain the paths to each track on the matched albums. Enable this option to store paths to folders instead. Default: ``no``. - **raw**: Instead of creating a temporary m3u playlist and then opening it, simply call the command with the paths returned by the query as arguments. Default: ``no``. - **warning_threshold**: Set the minimum number of files to play which will trigger a warning to be emitted. If set to ``no``, warning are never issued. Default: 100. - **bom**: Set whether or not a UTF-8 Byte Order Mark should be emitted into the m3u file. If you're using foobar2000 or Winamp, this is needed. Default: ``no``. Optional Arguments ------------------ The ``--args`` (or ``-A``) flag to the ``play`` command lets you specify additional arguments for your player command. Options are inserted after the configured ``command`` string and before the playlist filename. For example, if you have the plugin configured like this: :: play: command: mplayer -quiet and you occasionally want to shuffle the songs you play, you can type: :: $ beet play --args -shuffle to get beets to execute this command: :: mplayer -quiet -shuffle /path/to/playlist.m3u instead of the default. If you need to insert arguments somewhere other than the end of the ``command`` string, use ``$args`` to indicate where to insert them. For example: :: play: command: mpv $args --playlist indicates that you need to insert extra arguments before specifying the playlist. Some players require a different syntax. For example, with ``mpv`` the optional ``$playlist`` variable can be used to match the syntax of the ``--playlist`` option: :: play: command: mpv $args --playlist=$playlist The ``--yes`` (or ``-y``) flag to the ``play`` command will skip the warning message if you choose to play more items than the **warning_threshold** value usually allows. The ``--randomize`` (or ``-R``) flag shuffles the order of playlist entries before passing it to the player: :: $ beet play --randomize my query Note on the Leakage of the Generated Playlists ---------------------------------------------- Because the command that will open the generated ``.m3u`` files can be arbitrarily configured by the user, beets won't try to delete those files. For this reason, using this plugin will leave one or several playlist(s) in the directory selected to create temporary files (Most likely ``/tmp/`` on Unix-like systems. See tempfile.tempdir_ in the Python docs.). Leaking those playlists until they are externally wiped could be an issue for privacy or storage reasons. If this is the case for you, you might want to use the ``raw`` config option described above. .. _tempfile.tempdir: https://docs.python.org/3/library/tempfile.html#tempfile.tempdir ================================================ FILE: docs/plugins/playlist.rst ================================================ Playlist Plugin =============== ``playlist`` is a plugin to use playlists in m3u format. To use it, enable the ``playlist`` plugin in your configuration (see :ref:`using-plugins`). Then configure your playlists like this: :: playlist: auto: no relative_to: ~/Music playlist_dir: ~/.mpd/playlists forward_slash: no It is possible to query the library based on a playlist by specifying its absolute path: :: $ beet ls playlist:/path/to/someplaylist.m3u The plugin also supports referencing playlists by name. The playlist is then searched in the playlist_dir and the ".m3u" extension is appended to the name: :: $ beet ls playlist:anotherplaylist A playlist query will use the paths found in the playlist file to match items in the beets library. ``playlist:`` submits a regular beets :ref:`query <queries>` similar to a :ref:`specific fields query <fieldsquery>`. If you want the list in any particular order, you can use the standard beets query syntax for :ref:`sorting <query-sort>`: :: $ beet ls playlist:/path/to/someplaylist.m3u artist+ year+ Playlist queries do not reflect the original order in the m3u file. The plugin can also update playlists in the playlist directory automatically every time an item is moved or deleted. This can be controlled by the ``auto`` configuration option. Configuration ------------- To configure the plugin, make a ``playlist:`` section in your configuration file. In addition to the ``playlists`` described above, the other configuration options are: - **auto**: If this is set to ``yes``, then anytime an item in the library is moved or removed, the plugin will update all playlists in the ``playlist_dir`` directory that contain that item to reflect the change. Default: ``no`` - **playlist_dir**: Where to read playlist files from. Default: The current working directory (i.e., ``'.'``). - **relative_to**: Interpret paths in the playlist files relative to a base directory. Instead of setting it to a fixed path, it is also possible to set it to ``playlist`` to use the playlist's parent directory or to ``library`` to use the library directory. Default: ``library`` - **forward_slash**: Forces forward slashes in the generated playlist files. If you intend to use this plugin to generate playlists for MPD on Windows, set this to yes. Default: Use system separator. ================================================ FILE: docs/plugins/plexupdate.rst ================================================ PlexUpdate Plugin ================= ``plexupdate`` is a very simple plugin for beets that lets you automatically update Plex_'s music library whenever you change your beets library. Firstly, install ``beets`` with ``plexupdate`` extra .. code-block:: console pip install "beets[plexupdate]" Then, enable ``plexupdate`` plugin it in your configuration (see :ref:`using-plugins`). Optionally, configure the specifics of your Plex server. You can do this using a ``plex:`` section in your ``config.yaml``: .. code-block:: yaml plex: host: "localhost" port: 32400 token: "TOKEN" The ``token`` key is optional: you'll need to use it when in a Plex Home (see Plex's own `documentation about tokens`_). With that all in place, you'll see beets send the "update" command to your Plex server every time you change your beets library. .. _documentation about tokens: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ .. _plex: https://watch.plex.tv/ Configuration ------------- The available options under the ``plex:`` section are: - **host**: The Plex server name. Default: ``localhost``. - **port**: The Plex server port. Default: 32400. - **token**: The Plex Home token. Default: Empty. - **library_name**: The name of the Plex library to update. Default: ``Music`` - **secure**: Use secure connections to the Plex server. Default: ``False`` - **ignore_cert_errors**: Ignore TLS certificate errors when using secure connections. Default: ``False`` ================================================ FILE: docs/plugins/random.rst ================================================ Random Plugin ============= The ``random`` plugin provides a command that randomly selects tracks or albums from your library. This can be helpful if you need some help deciding what to listen to. First, enable the plugin named ``random`` (see :ref:`using-plugins`). You'll then be able to use the ``beet random`` command: .. code-block:: shell beet random >> Aesop Rock - None Shall Pass - The Harbor Is Yours Usage ----- The basic command selects and displays a single random track. Several options allow you to customize the selection: .. code-block:: shell Usage: beet random [options] Options: -h, --help show this help message and exit -n NUMBER, --number=NUMBER number of objects to choose -e, --equal-chance each field has the same chance -t TIME, --time=TIME total length in minutes of objects to choose --field=FIELD field to use for equal chance sampling (default: albumartist) -a, --album match albums instead of tracks -p PATH, --path=PATH print paths for matched items or albums -f FORMAT, --format=FORMAT print with custom format Detailed Options ---------------- ``-n, --number=NUMBER`` Select multiple items at once. The default is 1. ``-e, --equal-chance`` Give each distinct value of a field an equal chance of being selected. This prevents artists with many albums/tracks from dominating the selection. **Implementation note:** When this option is used, the plugin: 1. Groups items by the specified field 2. Shuffles items within each group 3. Randomly selects groups, then items from those groups 4. Continues until all groups are exhausted Items without the specified field (``--field``) value are excluded from the selection. ``--field=FIELD`` Specify which field to use for equal chance sampling. Default is ``albumartist``. ``-t, --time=TIME`` Select items whose total duration (in minutes) is approximately equal to TIME. The plugin will continue adding items until the total exceeds the requested time. ``-a, --album`` Operate on albums instead of tracks. ``-p, --path`` Output filesystem paths instead of formatted metadata. ``-f, --format=FORMAT`` Use a custom format string for output. See :doc:`/reference/query` for format syntax. Examples -------- Select multiple items: .. code-block:: shell # Select 5 random tracks beet random -n 5 # Select 3 random albums beet random -a -n 3 Control selection fairness: .. code-block:: shell # Ensure equal chance per artist (default field: albumartist) beet random -e # Ensure equal chance per genre beet random -e --field genre Select by total playtime: .. code-block:: shell # Select tracks totaling 60 minutes (1 hour) beet random -t 60 # Select albums totaling 120 minutes (2 hours) beet random -a -t 120 Custom output formats: .. code-block:: shell # Print only artist and title beet random -f '$artist - $title' # Print file paths beet random -p # Print album paths beet random -a -p ================================================ FILE: docs/plugins/replace.rst ================================================ Replace Plugin ============== The ``replace`` plugin provides a command that replaces the audio file of a track, while keeping the name and tags intact. It should save some time when you get the wrong version of a song. Enable the ``replace`` plugin in your configuration (see :ref:`using-plugins`) and then type: :: $ beet replace <query> <path> The plugin will show you a list of files for you to pick from, and then ask for confirmation. Consider using the ``replaygain`` command from the :doc:`/plugins/replaygain` plugin, if you usually use it during imports. ================================================ FILE: docs/plugins/replaygain.rst ================================================ ReplayGain Plugin ================= This plugin adds support for ReplayGain_, a technique for normalizing audio playback levels. .. _replaygain: https://wiki.hydrogenaudio.org/index.php?title=ReplayGain Installation ------------ This plugin can use one of many backends to compute the ReplayGain values: GStreamer, mp3gain (and its cousins, aacgain and mp3rgain), Python Audio Tools or ffmpeg. ffmpeg and mp3gain can be easier to install. mp3gain supports fewer audio formats than the other backends. Once installed, this plugin analyzes all files during the import process. This can be a slow process; to instead analyze after the fact, disable automatic analysis and use the ``beet replaygain`` command (see below). To speed up analysis with some of the available backends, this plugin processes tracks or albums (when using the ``-a`` option) in parallel. By default, a single thread is used per logical core of your CPU. GStreamer ~~~~~~~~~ To use GStreamer_ for ReplayGain analysis, you will of course need to install GStreamer and plugins for compatibility with your audio files. You will need at least GStreamer 1.0 and `PyGObject 3.x`_ (a.k.a. ``python-gi``). .. _gstreamer: https://gstreamer.freedesktop.org/ .. _pygobject 3.x: https://pygobject.gnome.org/ Then, install ``beets`` with ``replaygain`` extra which installs ``GStreamer`` bindings for Python .. code-block:: bash pip install "beets[replaygain]" Lastly, enable the ``replaygain`` plugin in your configuration (see :ref:`using-plugins`) and specify the GStreamer backend by adding this to your configuration file: :: replaygain: backend: gstreamer The GStreamer backend does not support parallel analysis. Supported ``command`` backends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In order to use this backend, you will need to install a supported command-line tool: - mp3gain_ (MP3 only) - aacgain_ (MP3, AAC/M4A) - mp3rgain_ (MP3, AAC/M4A) mp3gain +++++++ - On Linux, mp3gain_ is probably in your repositories. On Debian or Ubuntu, for example, you can run ``apt-get install mp3gain``. - On Windows, download and install mp3gain_. aacgain +++++++ - On macOS, install via Homebrew_: ``brew install aacgain``. - For other platforms, download from aacgain_ or use a compatible fork if available for your system. mp3rgain ++++++++ mp3rgain_ is a modern Rust rewrite of ``mp3gain`` that also supports AAC/M4A files. It addresses security vulnerability CVE-2019-18359 present in the original mp3gain and works on modern systems including Windows 11 and macOS with Apple Silicon. - On macOS, install via Homebrew_: ``brew install mp3rgain``. - On Linux, install via Nix: ``nix-env -iA nixpkgs.mp3rgain`` or from your distribution packaging (for example, AUR on Arch Linux). - On Windows, download and install mp3rgain_. Configuration +++++++++++++ .. code-block:: yaml replaygain: backend: command command: # mp3rgain, mp3gain, or aacgain If beets doesn't automatically find the command executable, you can configure the path explicitly like so: .. code-block:: yaml replaygain: command: /Applications/MacMP3Gain.app/Contents/Resources/aacgain .. _aacgain: https://github.com/dgilman/aacgain .. _homebrew: https://brew.sh .. _mp3gain: https://sourceforge.net/projects/mp3gain/download.php .. _mp3rgain: https://github.com/M-Igashi/mp3rgain Python Audio Tools ~~~~~~~~~~~~~~~~~~ This backend uses the `Python Audio Tools`_ package to compute ReplayGain for a range of different file formats. The package is not available via PyPI; it must be installed manually (only versions preceding 3.x are compatible). On OS X, most of the dependencies can be installed with Homebrew_: :: brew install mpg123 mp3gain vorbisgain faad2 libvorbis The Python Audio Tools backend does not support parallel analysis. .. _python audio tools: https://sourceforge.net/projects/audiotools/ ffmpeg ~~~~~~ This backend uses ffmpeg to calculate EBU R128 gain values. To use it, install the ffmpeg_ command-line tool and select the ``ffmpeg`` backend in your config file. .. _ffmpeg: https://ffmpeg.org Configuration ------------- To configure the plugin, make a ``replaygain:`` section in your configuration file. The available options are: - **auto**: Enable ReplayGain analysis during import. Default: ``yes``. - **threads**: The number of parallel threads to run the analysis in. Overridden by ``--threads`` at the command line. Default: # of logical CPU cores - **parallel_on_import**: Whether to enable parallel analysis during import. As of now this ReplayGain data is not written to files properly, so this option is disabled by default. If you wish to enable it, remember to run ``beet write`` after importing to actually write to the imported files. Default: ``no`` - **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools`` or ``ffmpeg``. Default: ``command``. - **overwrite**: On import, re-analyze files that already have ReplayGain tags. Note that, for historical reasons, the name of this option is somewhat unfortunate: It does not decide whether tags are written to the files (which is controlled by the :ref:`import.write <config-import-write>` option). Default: ``no``. - **targetlevel**: A number of decibels for the target loudness level for files using ``REPLAYGAIN_`` tags. Default: ``89``. - **r128_targetlevel**: The target loudness level in decibels (i.e. ``<loudness in LUFS> + 107``) for files using ``R128_`` tags. Default: 84 (Use ``83`` for ATSC A/85, ``84`` for EBU R128 or ``89`` for ReplayGain 2.0.) - **r128**: A space separated list of formats that will use ``R128_`` tags with integer values instead of the common ``REPLAYGAIN_`` tags with floating point values. Requires the "ffmpeg" backend. Default: ``Opus``. - **per_disc**: Calculate album ReplayGain on disc level instead of album level. Default: ``no`` These options only work with the "command" backend: - **command**: Name or path to your command backend of choice: either of ``mp3gain``, ``aacgain`` or ``mp3rgain``. - **noclip**: Reduce the amount of ReplayGain adjustment to whatever amount would keep clipping from occurring. Default: ``yes``. This option only works with the "ffmpeg" backend: - **peak**: Either ``true`` (the default) or ``sample``. ``true`` is more accurate but slower. Manual Analysis --------------- By default, the plugin will analyze all items an albums as they are implemented. However, you can also manually analyze files that are already in your library. Use the ``beet replaygain`` command: :: $ beet replaygain [-Waf] [QUERY] The ``-a`` flag analyzes whole albums instead of individual tracks. Provide a query (see :doc:`/reference/query`) to indicate which items or albums to analyze. Files that already have ReplayGain values are skipped unless ``-f`` is supplied. Use ``-w`` (write tags) or ``-W`` (don't write tags) to control whether ReplayGain tags are written into the music files, or stored in the beets database only (the default is to use :ref:`the importer's configuration <config-import-write>`). To execute with a different number of threads, call ``beet replaygain --threads N``: :: $ beet replaygain --threads N [-Waf] [QUERY] with N any integer. To disable parallelism, use ``--threads 0``. ReplayGain analysis is not fast, so you may want to disable it during import. Use the ``auto`` config option to control this: :: replaygain: auto: no ================================================ FILE: docs/plugins/rewrite.rst ================================================ Rewrite Plugin ============== The ``rewrite`` plugin lets you easily substitute values in your templates and path formats. Specifically, it is intended to let you *canonicalize* names such as artists: for example, perhaps you want albums from The Jimi Hendrix Experience to be sorted into the same folder as solo Hendrix albums. To use field rewriting, first enable the ``rewrite`` plugin (see :ref:`using-plugins`). Then, make a ``rewrite:`` section in your config file to contain your rewrite rules. Each rule consists of a field name, a regular expression pattern, and a replacement value. Rules are written ``fieldname regex: replacement``. For example, this line implements the Jimi Hendrix example above: :: rewrite: artist The Jimi Hendrix Experience: Jimi Hendrix This will make ``$artist`` in your templates expand to "Jimi Hendrix" where it would otherwise be "The Jimi Hendrix Experience". The pattern is a case-insensitive regular expression. This means you can use ordinary regular expression syntax to match multiple artists. For example, you might use: :: rewrite: artist .*jimi hendrix.*: Jimi Hendrix As a convenience, the plugin applies patterns for the ``artist`` field to the ``albumartist`` field as well. (Otherwise, you would probably want to duplicate every rule for ``artist`` and ``albumartist``.) A word of warning: This plugin theoretically only applies to templates and path formats; it initially does not modify files' metadata tags or the values tracked by beets' library database, but since it *rewrites all field lookups*, it modifies the file's metadata anyway. See comments in issue :bug:`2786`. As an alternative to this plugin the :doc:`/plugins/substitute` could be used. ================================================ FILE: docs/plugins/scrub.rst ================================================ Scrub Plugin ============ The ``scrub`` plugin lets you remove extraneous metadata from files' tags. If you'd prefer never to see crufty tags that come from other tools, the plugin can automatically remove all non-beets-tracked tags whenever a file's metadata is written to disk by removing the tag entirely before writing new data. The plugin also provides a command that lets you manually remove files' tags. Automatic Scrubbing ------------------- To automatically remove files' tags before writing new ones, enable ``scrub`` plugin in your configuration (see :ref:`using-plugins`) and install ``beets`` with ``scrub`` extra .. code-block:: bash pip install "beets[scrub]" When importing new files (with ``import.write`` turned on) or modifying files' tags with the ``beet modify`` command, beets will first strip all types of tags entirely and then write the database-tracked metadata to the file. This behavior can be disabled with the ``auto`` config option (see below). Manual Scrubbing ---------------- The ``scrub`` command provided by this plugin removes tags from files and then rewrites their database-tracked metadata. To run it, just type ``beet scrub QUERY`` where ``QUERY`` matches the tracks to be scrubbed. Use this command with caution, however, because any information in the tags that is out of sync with the database will be lost. The ``-W`` (or ``--nowrite``) option causes the command to just remove tags but not restore any information. This will leave the files with no metadata whatsoever. Configuration ------------- To configure the plugin, make a ``scrub:`` section in your configuration file. There is one option: - **auto**: Enable metadata stripping during import. Default: ``yes``. ================================================ FILE: docs/plugins/shared_metadata_source_config.rst ================================================ .. _data_source_mismatch_penalty: .. conf:: data_source_mismatch_penalty :default: 0.5 Penalty applied when the data source of a match candidate differs from the original source of your existing tracks. Any decimal number between 0.0 and 1.0 This setting controls how much to penalize matches from different metadata sources during import. The penalty is applied when beets detects that a match candidate comes from a different data source than what appears to be the original source of your music collection. **Example configurations:** .. code-block:: yaml # Prefer MusicBrainz over Discogs when sources don't match plugins: musicbrainz discogs musicbrainz: data_source_mismatch_penalty: 0.3 # Lower penalty = preferred discogs: data_source_mismatch_penalty: 0.8 # Higher penalty = less preferred .. code-block:: yaml # Do not penalise candidates from Discogs at all plugins: musicbrainz discogs musicbrainz: data_source_mismatch_penalty: 0.5 discogs: data_source_mismatch_penalty: 0.0 .. code-block:: yaml # Disable cross-source penalties entirely plugins: musicbrainz discogs musicbrainz: data_source_mismatch_penalty: 0.0 discogs: data_source_mismatch_penalty: 0.0 .. tip:: The last configuration is equivalent to setting: .. code-block:: yaml match: distance_weights: data_source: 0.0 # Disable data source matching .. conf:: source_weight :default: 0.5 .. deprecated:: 2.5 Use `data_source_mismatch_penalty`_ instead. .. conf:: search_limit :default: 5 Maximum number of search results to return. ================================================ FILE: docs/plugins/smartplaylist.rst ================================================ Smart Playlist Plugin ===================== ``smartplaylist`` is a plugin to generate smart playlists in m3u format based on beets queries every time your library changes. This plugin is specifically created to work well with `MPD's`_ playlist functionality. .. _mpd's: https://www.musicpd.org/ To use it, enable the ``smartplaylist`` plugin in your configuration (see :ref:`using-plugins`). Then configure your smart playlists like the following example: :: smartplaylist: relative_to: ~/Music playlist_dir: ~/.mpd/playlists forward_slash: no playlists: - name: all.m3u query: '' - name: beatles.m3u query: 'artist:Beatles' You can generate as many playlists as you want by adding them to the ``playlists`` section, using beets query syntax (see :doc:`/reference/query`) for ``query`` and the file name to be generated for ``name``. The query will be split using shell-like syntax, so if you need to use spaces in the query, be sure to quote them (e.g., ``artist:"The Beatles"``). If you have existing files with the same names, you should back them up---they will be overwritten when the plugin runs. For more advanced usage, you can use template syntax (see :doc:`/reference/pathformat/`) in the ``name`` field. For example: :: - name: 'ReleasedIn$year.m3u' query: 'year::201(0|1)' This will query all the songs in 2010 and 2011 and generate the two playlist files ``ReleasedIn2010.m3u`` and ``ReleasedIn2011.m3u`` using those songs. You can also gather the results of several queries by putting them in a list. (Items that match both queries are not included twice.) For example: :: - name: 'BeatlesUniverse.m3u' query: ['artist:beatles', 'genre:"beatles cover"'] Note that since beets query syntax is in effect, you can also use sorting directives: :: - name: 'Chronological Beatles' query: 'artist:Beatles year+' - name: 'Mixed Rock' query: ['artist:Beatles year+', 'artist:"Led Zeppelin" bitrate+'] The former case behaves as expected, however please note that in the latter the sorts will be merged: ``year+ bitrate+`` will apply to both the Beatles and Led Zeppelin. If that bothers you, please get in touch. For querying albums instead of items (mainly useful with extensible fields), use the ``album_query`` field. ``query`` and ``album_query`` can be used at the same time. The following example gathers single items but also items belonging to albums that have a ``for_travel`` extensible field set to 1: :: - name: 'MyTravelPlaylist.m3u' album_query: 'for_travel:1' query: 'for_travel:1' By default, each playlist is automatically regenerated at the end of the session if an item or album it matches changed in the library database. To force regeneration, you can invoke it manually from the command line: :: $ beet splupdate This will regenerate all smart playlists. You can also specify which ones you want to regenerate: :: $ beet splupdate BeatlesUniverse.m3u MyTravelPlaylist You can also use this plugin together with the :doc:`mpdupdate`, in order to automatically notify MPD of the playlist change, by adding ``mpdupdate`` to the ``plugins`` line in your config file *after* the ``smartplaylist`` plugin. While changing existing playlists in the beets configuration it can help to use the ``--pretend`` option to find out if the edits work as expected. The results of the queries will be printed out instead of being written to the playlist file. :: $ beet splupdate --pretend BeatlesUniverse.m3u The ``pretend_paths`` configuration option sets whether the items should be displayed as per the user's ``format_item`` setting or what the file paths as they would be written to the m3u file look like. In case you want to export additional fields from the beets database into the generated playlists, you can do so by specifying them within the ``fields`` configuration option and setting the ``output`` option to ``extm3u``. For instance the following configuration exports the ``id`` and ``genre`` fields: :: smartplaylist: playlist_dir: /data/playlists relative_to: /data/playlists output: extm3u fields: - id - genres playlists: - name: all.m3u query: '' Values of additional fields are URL-encoded. A resulting ``all.m3u`` file could look as follows: :: #EXTM3U #EXTINF:805 id="1931" genres="Rock%3B%20Pop",Led Zeppelin - Stairway to Heaven ../music/singles/Led Zeppelin/Stairway to Heaven.mp3 To give a usage example, the webm3u_ and Beetstream_ plugins read the exported ``id`` field, allowing you to serve your local m3u playlists via HTTP. .. _beetstream: https://github.com/BinaryBrain/Beetstream .. _webm3u: https://github.com/mgoltzsche/beets-webm3u Configuration ------------- To configure the plugin, make a ``smartplaylist:`` section in your configuration file. In addition to the ``playlists`` described above, the other configuration options are: - **auto**: Regenerate the playlist after every database change. Default: ``yes``. - **playlist_dir**: Where to put the generated playlist files. Default: The current working directory (i.e., ``'.'``). - **dest_regen**: Regenerate the destination path as ``move`` or ``convert`` commands would do. This operation will happen before ``relative_to`` and ``prefix``. Helpful to generate playlists compatible with the ``convert`` plugin when items have been imported with the ``-C -M`` options. Default: ``false``. - **relative_to**: Generate paths in the playlist files relative to a base directory. If you intend to use this plugin to generate playlists for MPD, point this to your MPD music directory. Default: Use absolute paths. - **forward_slash**: Forces forward slashes in the generated playlist files. If you intend to use this plugin to generate playlists for MPD on Windows, set this to yes. Default: Use system separator. - **prefix**: Prepend this string to every path in the playlist file. For example, you could use the URL for a server where the music is stored. Default: empty string. - **urlencode**: URL-encode all paths. Default: ``no``. - **pretend_paths**: When running with ``--pretend``, show the actual file paths that will be written to the m3u file. Default: ``false``. - **uri_format**: Template with an ``$id`` placeholder used generate a playlist item URI, e.g. ``http://beets:8337/item/$id/file``. When this option is specified, the local path-related options ``dest_regen``, ``prefix``, ``relative_to``, ``forward_slash`` and ``urlencode`` are ignored. - **output**: Specify the playlist format: m3u|extm3u. Default ``m3u``. - **fields**: Specify the names of the additional item fields to export into the playlist. This allows using e.g. the ``id`` field within other tools such as the webm3u_ and Beetstream_ plugins. To use this option, you must set the ``output`` option to ``extm3u``. For many configuration options, there is a corresponding CLI option, e.g. ``--playlist-dir``, ``--dest-regen``, ``--relative-to``, ``--prefix``, ``--forward-slash``, ``--urlencode``, ``--uri-format``, ``--output``, ``--pretend-paths``. CLI options take precedence over those specified within the configuration file. ================================================ FILE: docs/plugins/sonosupdate.rst ================================================ SonosUpdate Plugin ================== The ``sonosupdate`` plugin lets you automatically update Sonos_'s music library whenever you change your beets library. To use ``sonosupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). To use the ``sonosupdate`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``sonosupdate`` extra pip install "beets[sonosupdate]" With that all in place, you'll see beets send the "update" command to your Sonos controller every time you change your beets library. .. _sonos: https://www.sonos.com/ ================================================ FILE: docs/plugins/spotify.rst ================================================ Spotify Plugin ============== The ``spotify`` plugin generates Spotify_ playlists from tracks in your library with the ``beet spotify`` command using the `Spotify Search API`_. Also, the plugin can use the Spotify Album_ and Track_ APIs to provide metadata matches for the importer. .. _album: https://developer.spotify.com/documentation/web-api/reference/get-an-album .. _spotify: https://open.spotify.com/ .. _spotify search api: https://developer.spotify.com/documentation/web-api/reference/search .. _track: https://developer.spotify.com/documentation/web-api/reference/get-track Why Use This Plugin? -------------------- - You're a Beets user and Spotify user already. - You have playlists or albums you'd like to make available in Spotify from Beets without having to search for each artist/album/track. - You want to check which tracks in your library are available on Spotify. - You want to autotag music with metadata from the Spotify API. - You want to obtain track popularity and audio features (e.g., danceability) Basic Usage ----------- First, enable the ``spotify`` plugin (see :ref:`using-plugins`). Then, use the ``spotify`` command with a beets query: :: beet spotify [OPTIONS...] QUERY Here's an example: :: $ beet spotify "In The Lonely Hour" Processing 14 tracks... https://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4 https://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS [...] Command-line options include: - ``-m MODE`` or ``--mode=MODE`` where ``MODE`` is either "list" or "open" controls whether to print out the playlist (for copying and pasting) or open it in the Spotify app. (See below.) - ``--show-failures`` or ``-f``: List the tracks that did not match a Spotify ID. You can enter the URL for an album or song on Spotify at the ``enter Id`` prompt during import: :: Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i Enter release ID: https://open.spotify.com/album/2rFYTHFBLQN3AYlrymBPPA Configuration ------------- This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`. Default ~~~~~~~ .. code-block:: yaml spotify: mode: list region_filter: show_failures: no tiebreak: popularity regex: [] search_query_ascii: no client_id: REDACTED client_secret: REDACTED tokenfile: spotify_token.json data_source_mismatch_penalty: 0.5 search_limit: 5 .. conf:: mode :default: list Controls how the playlist is output: - ``list``: Print out the playlist as a list of links. This list can then be pasted in to a new or existing Spotify playlist. - ``open``: This mode actually sends a link to your default browser with instructions to open Spotify with the playlist you created. Until this has been tested on all platforms, it will remain optional. .. conf:: region_filter :default: A two-character country abbreviation, to limit results to that market. .. conf:: show_failures :default: no List each lookup that does not return a Spotify ID (and therefore cannot be added to a playlist). .. conf:: tiebreak :default: popularity How to choose the candidate if there is more than one identical result. For example, there might be multiple releases of the same album. - ``popularity``: pick the more popular candidate - ``first``: pick the first candidate .. conf:: regex :default: [] An array of regex transformations to perform on the track/album/artist fields before sending them to Spotify. Can be useful for changing certain abbreviations, like ft. -> feat. For example: .. code-block:: yaml regex: - field: albumartist search: Something replace: Replaced - field: title search: Something Else replace: AlsoReplaced .. conf:: search_query_ascii :default: no If enabled, the search query will be converted to ASCII before being sent to Spotify. Converting searches to ASCII can enhance search results in some cases, but in general, it is not recommended. For instance, ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice ``×!=x``). .. include:: ./shared_metadata_source_config.rst Obtaining Track Popularity and Audio Features from Spotify ---------------------------------------------------------- Spotify provides information on track popularity_ and audio features_ that can be used for music discovery. .. _features: https://developer.spotify.com/documentation/web-api/reference/get-audio-features .. _popularity: https://developer.spotify.com/documentation/web-api/reference/get-track The ``spotify`` plugin provides an additional command ``spotifysync`` to obtain these track attributes from Spotify: - ``beet spotifysync [-f]``: obtain popularity and audio features information for every track in the library. By default, ``spotifysync`` will skip tracks that already have this information populated. Using the ``-f`` or ``-force`` option will download the data even for tracks that already have it. Please note that ``spotifysync`` works on tracks that have the Spotify track identifiers. So run ``spotifysync`` only after importing your music, during which Spotify identifiers will be added for tracks where Spotify is chosen as the tag source. In addition to ``popularity``, the command currently sets these audio features for all tracks with a Spotify track ID: - ``acousticness`` - ``danceability`` - ``energy`` - ``instrumentalness`` - ``key`` - ``liveness`` - ``loudness`` - ``mode`` - ``speechiness`` - ``tempo`` - ``time_signature`` - ``valence`` ================================================ FILE: docs/plugins/subsonicplaylist.rst ================================================ Subsonic Playlist Plugin ======================== The ``subsonicplaylist`` plugin allows to import playlists from a subsonic server. This is done by retrieving the track info from the subsonic server, searching for them in the beets library, and adding the playlist names to the ``subsonic_playlist`` tag of the found items. The content of the tag has the format: subsonic_playlist: ";first playlist;second playlist;" To get all items in a playlist use the query ``;playlist name;``. Command Line Usage ------------------ To use the ``subsonicplaylist`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then use it by invoking the ``subsonicplaylist`` command. Next, configure the plugin to connect to your Subsonic server, like this: :: subsonicplaylist: base_url: http://subsonic.example.com username: someUser password: somePassword After this you can import your playlists by invoking the ``subsonicplaylist`` command. By default only the tags of the items found for playlists will be updated. This means that, if one imported a playlist, then delete one song from it and imported the playlist again, the deleted song will still have the playlist set in its ``subsonic_playlist`` tag. To solve this problem one can use the ``-d/--delete`` flag. This resets all ``subsonic_playlist`` tag before importing playlists. Here's an example configuration with all the available options and their default values: :: subsonicplaylist: base_url: "https://your.subsonic.server" delete: no playlist_ids: [] playlist_names: [] username: '' password: '' The ``base_url``, ``username``, and ``password`` options are required. ================================================ FILE: docs/plugins/subsonicupdate.rst ================================================ SubsonicUpdate Plugin ===================== ``subsonicupdate`` is a very simple plugin for beets that lets you automatically update Subsonic_'s index whenever you change your beets library. .. _subsonic: https://www.subsonic.org/pages/index.jsp To use ``subsonicupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Subsonic server. You can do that using a ``subsonic:`` section in your ``config.yaml``, which looks like this: :: subsonic: url: https://example.com:443/subsonic user: username pass: password auth: token With that all in place, this plugin will send a REST API call to your Subsonic server every time you change your beets library. Due to a current limitation of the API, all libraries visible to that user will be scanned. If the :doc:`/plugins/smartplaylist` is used, creating or changing any playlist will trigger a Subsonic update as well. This plugin requires Subsonic with an active Premium license (or active trial) or any other `Subsonic API compatible`_ server implementing the ``startScan`` endpoint. .. _subsonic api compatible: https://www.subsonic.org/pages/api.jsp Configuration ------------- The available options under the ``subsonic:`` section are: - **url**: The Subsonic server resource. Default: ``http://localhost:4040`` - **user**: The Subsonic user. Default: ``admin`` - **pass**: The Subsonic user password. (This may either be a clear-text password or hex-encoded with the prefix ``enc:``.) Default: ``admin`` - **auth**: The authentication method. Possible choices are ``token`` or ``password``. ``token`` authentication is preferred to avoid sending cleartext password. ================================================ FILE: docs/plugins/substitute.rst ================================================ Substitute Plugin ================= The ``substitute`` plugin lets you easily substitute values in your templates and path formats. Specifically, it is intended to let you *canonicalize* names such as artists: For example, perhaps you want albums from The Jimi Hendrix Experience to be sorted into the same folder as solo Hendrix albums. This plugin is intended as a replacement for the ``rewrite`` plugin. While the ``rewrite`` plugin modifies the metadata, this plugin does not. Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain your rules. Each rule consists of a case-insensitive regular expression pattern, and a replacement string. For example, you might use: .. code-block:: yaml substitute: .*jimi hendrix.*: Jimi Hendrix The replacement can be an expression utilising the matched regex, allowing us to create more general rules. Say for example, we want to sort all albums by multiple artists into the directory of the first artist. We can thus capture everything before the first ``,``, ``&`` or ``and``, and use this capture group in the output, discarding the rest of the string. .. code-block:: yaml substitute: ^(.*?)(,| &| and).*: \1 This would handle all the below cases in a single rule: | Bob Dylan and The Band -> Bob Dylan | Neil Young & Crazy Horse -> Neil Young | James Yorkston, Nina Persson & The Second Hand Orchestra -> James Yorkston To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example: .. code-block:: yaml paths: default: \%substitute{$albumartist}/$year - $album\%aunique{}/$track - $title ================================================ FILE: docs/plugins/the.rst ================================================ The Plugin ========== The ``the`` plugin allows you to move patterns in path formats. It's suitable, for example, for moving articles from string start to the end. This is useful for quick search on filesystems and generally looks good. Plugin does not change tags. By default plugin supports English "the, a, an", but custom regexp patterns can be added by user. How it works: :: The Something -> Something, The A Band -> Band, A An Orchestra -> Orchestra, An To use the ``the`` plugin, enable it (see :doc:`/plugins/index`) and then use a template function called ``%the`` in path format expressions: :: paths: default: %the{$albumartist}/($year) $album/$track $title The default configuration moves all English articles to the end of the string, but you can override these defaults to make more complex changes. Configuration ------------- To configure the plugin, make a ``the:`` section in your configuration file. The available options are: - **a**: Handle "A/An" moves. Default: ``yes``. - **the**: handle "The" moves. Default: ``yes``. - **patterns**: Custom regexp patterns, space-separated. Custom patterns are case-insensitive regular expressions. Patterns can be matched anywhere in the string (not just the beginning), so use ``^`` if you intend to match leading words. Default: ``[]``. - **strip**: Remove the article altogether instead of moving it to the end. Default: ``no``. - **format**: A Python format string for the output. Use ``{0}`` to indicate the part without the article and ``{1}`` for the article. Spaces are already trimmed from ends of both parts. Default: ``'{0}, {1}'``. ================================================ FILE: docs/plugins/thumbnails.rst ================================================ Thumbnails Plugin ================= The ``thumbnails`` plugin creates thumbnails for your album folders with the album cover. This works on freedesktop.org-compliant file managers such as Nautilus or Thunar, and is therefore POSIX-only. To use the ``thumbnails`` plugin, enable ``thumbnails`` and :doc:`/plugins/fetchart` in your configuration (see :ref:`using-plugins`) and install ``beets`` with ``thumbnails`` and ``fetchart`` extras .. code-block:: bash pip install "beets[fetchart,thumbnails]" ``thumbnails`` need to resize the covers, and therefore requires either ImageMagick_ or Pillow_. .. _imagemagick: https://imagemagick.org/ .. _pillow: https://github.com/python-pillow/Pillow Configuration ------------- To configure the plugin, make a ``thumbnails`` section in your configuration file. The available options are - **auto**: Whether the thumbnail should be automatically set on import. Default: ``yes``. - **force**: Generate the thumbnail even when there's one that seems fine (more recent than the cover art). Default: ``no``. - **dolphin**: Generate dolphin-compatible thumbnails. Dolphin (KDE file explorer) does not respect freedesktop.org's standard on thumbnails. This functionality replaces the :doc:`/plugins/freedesktop` Default: ``no`` Usage ----- The ``thumbnails`` command provided by this plugin creates a thumbnail for albums that match a query (see :doc:`/reference/query`). ================================================ FILE: docs/plugins/titlecase.rst ================================================ Titlecase Plugin ================ The ``titlecase`` plugin lets you format tags and paths in accordance with the titlecase guidelines in the `New York Times Manual of Style`_ and uses the `python titlecase library`_. Motivation for this plugin comes from a desire to resolve differences in style between databases sources. For example, `MusicBrainz style`_ follows standard title case rules, except in the case of terms that are deemed generic, like "mix" and "remix". On the other hand, `Discogs guidelines`_ recommend capitalizing the first letter of each word, even for small words like "of" and "a". This plugin aims to achieve a middle ground between disparate approaches to casing, and bring more consistency to titles in your library. .. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005006334-Database-Guidelines-1-General-Rules#Capitalization_And_Grammar .. _musicbrainz style: https://musicbrainz.org/doc/Style .. _new york times manual of style: https://search.worldcat.org/en/title/946964415 .. _python titlecase library: https://pypi.org/project/titlecase/ Installation ------------ To use the ``titlecase`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``titlecase`` extra: .. code-block:: bash pip install "beets[titlecase]" If you'd like to just use the path format expression, call ``%titlecase`` in your path formatter, and set ``auto`` to ``no`` in the configuration. :: paths: default: %titlecase($albumartist)/$titlecase($albumtitle)/$track $title You can now configure ``titlecase`` to your preference. Configuration ------------- This plugin offers several configuration options to tune its function to your preference. Default ~~~~~~~ .. code-block:: yaml titlecase: auto: yes fields: [] preserve: [] replace: [] separators: [] force_lowercase: no small_first_last: yes the_artist: yes all_lowercase: no all_caps: no after_choice: no .. conf:: auto :default: yes Whether to automatically apply titlecase to new imports. .. conf:: fields :default: [] A list of fields to apply the titlecase logic to. You must specify the fields you want to have modified in order for titlecase to apply changes to metadata. A good starting point is below, which will titlecase album titles, track titles, and all artist fields. .. code-block:: yaml titlecase: fields: - album - title - albumartist - albumartist_credit - albumartist_sort - albumartists - albumartists_credit - albumartists_sort - artist - artist_credit - artist_sort - artists - artists_credit - artists_sort .. conf:: preserve :default: [] List of words and phrases to preserve the case of. Without specifying ``DJ`` on the list, titlecase will format it as ``Dj``, or specify ``The Beatles`` to make sure ``With The Beatles`` is not capitalized as ``With the Beatles``. .. conf:: replace :default: [] The replace function takes place before any titlecasing occurs, and is intended to help normalize differences in puncuation styles. It accepts a list of tuples, with the first being the target, and the second being the replacement. An example configuration that enforces one style of quotation mark is below. .. code-block:: yaml titlecase: replace: - "’": "'" - "‘": "'" - "“": '"' - "”": '"' .. conf:: separators :default: [] A list of characters to treat as markers of new sentences. Helpful for split titles that might otherwise have a lowercase letter at the start of the second string. .. conf:: force_lowercase :default: no Force all strings to lowercase before applying titlecase, but can cause problems with all caps acronyms titlecase would otherwise recognize. .. conf:: small_first_last :default: yes An option from the base titlecase library. Controls capitalizing small words at the start of a sentence. With this turned off ``a`` and similar words will not be capitalized under any circumstance. .. conf:: the_artist :default: yes If a field name contains ``artist``, then any lowercase ``the`` will be capitalized. Useful for bands with ``The`` as part of the proper name, like ``Amyl and The Sniffers``. .. conf:: all_caps :default: no If the letters a-Z in a string are all caps, do not modify the string. Useful if you encounter a lot of acronyms. .. conf:: all_lowercase :default: no If the letters a-Z in a string are all lowercase, do not modify the string. Useful if you encounter a lot of stylized lowercase spellings, but otherwise want titlecase applied. .. conf:: after_choice :default: no By default, titlecase runs on the candidates that are received, adjusting them before you make your selection and creating different weight calculations. If you'd rather see the data as recieved from the database, set this to true to run after you make your tag choice. Dangerous Fields ~~~~~~~~~~~~~~~~ ``titlecase`` only ever modifies string fields, however, this doesn't prevent you from selecting a case sensitive field that another plugin or feature may rely on. In particular, including any of the following in your configuration could lead to unintended behavior: .. code-block:: bash acoustid_fingerprint acoustid_id artists_ids asin deezer_track_id format id isrc mb_workid mb_trackid mb_albumid mb_artistid mb_artistids mb_albumartistid mb_albumartistids mb_releasetrackid mb_releasegroupid bitrate_mode encoder_info encoder_settings Running Manually ---------------- From the command line, type: :: $ beet titlecase [QUERY] Configuration is drawn from the config file. Without a query the operation will be applied to the entire collection. ================================================ FILE: docs/plugins/types.rst ================================================ Types Plugin ============ The ``types`` plugin lets you declare types for attributes you use in your library. For example, you can declare that a ``rating`` field is numeric so that you can query it with ranges---which isn't possible when the field is considered a string (the default). Enable the ``types`` plugin as described in :doc:`/plugins/index` and then add a ``types`` section to your :doc:`configuration file </reference/config>`. The configuration section should map field name to one of ``int``, ``float``, ``bool``, or ``date``. Here's an example: :: types: rating: int Now you can assign numeric ratings to tracks and albums and use :ref:`range queries <numericquery>` to filter them.: :: beet modify "My favorite track" rating=5 beet ls rating:4..5 beet modify --album "My favorite album" rating=5 beet ls --album rating:4..5 ================================================ FILE: docs/plugins/unimported.rst ================================================ Unimported Plugin ================= The ``unimported`` plugin allows one to list all files in the library folder which are not listed in the beets library database, including art files. Command Line Usage ------------------ To use the ``unimported`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then use it by invoking the ``beet unimported`` command. The command will list all files in the library folder which are not imported. You can exclude file extensions or entire subdirectories using the configuration file: :: unimported: ignore_extensions: jpg png ignore_subdirectories: NonMusic data temp The default configuration lists all unimported files, ignoring no extensions. ================================================ FILE: docs/plugins/web.rst ================================================ Web Plugin ========== The ``web`` plugin is a very basic alternative interface to beets that supplements the CLI. It can't do much right now, and the interface is a little clunky, but you can use it to query and browse your music and---in browsers that support HTML5 Audio---you can even play music. While it's not meant to replace the CLI, a graphical interface has a number of advantages in certain situations. For example, when editing a tag, a natural CLI makes you retype the whole thing---common GUI conventions can be used to just edit the part of the tag you want to change. A graphical interface could also drastically increase the number of people who can use beets. Install ------- To use the ``web`` plugin, first enable it in your configuration (see :ref:`using-plugins`). Then, install ``beets`` with ``web`` extra .. code-block:: bash pip install "beets[web]" Run the Server -------------- Then just type ``beet web`` to start the server and go to http://localhost:8337/. This is what it looks like: .. image:: beetsweb.png You can also specify the hostname and port number used by the Web server. These can be specified on the command line or in the ``[web]`` section of your :doc:`configuration file </reference/config>`. On the command line, use ``beet web [HOSTNAME] [PORT]``. Or the configuration options below. Usage ----- Type queries into the little search box. Double-click a track to play it with HTML5 Audio. Configuration ------------- To configure the plugin, make a ``web:`` section in your configuration file. The available options are: - **host**: The server hostname. Set this to 0.0.0.0 to bind to all interfaces. Default: Bind to 127.0.0.1. - **port**: The server port. Default: 8337. - **cors**: The CORS allowed origin (see :ref:`web-cors`, below). Default: CORS is disabled. - **cors_supports_credentials**: Support credentials when using CORS (see :ref:`web-cors`, below). Default: CORS_SUPPORTS_CREDENTIALS is disabled. - **reverse_proxy**: If true, enable reverse proxy support (see :ref:`reverse-proxy`, below). Default: false. - **include_paths**: If true, includes paths in item objects. Default: false. - **readonly**: If true, DELETE and PATCH operations are not allowed. Only GET is permitted. Default: true. Implementation -------------- The Web backend is built using a simple REST+JSON API with the excellent Flask_ library. The frontend is a single-page application written with Backbone.js_. This allows future non-Web clients to use the same backend API. .. _backbone.js: https://backbonejs.org Eventually, to make the Web player really viable, we should use a Flash fallback for unsupported formats/browsers. There are a number of options for this: - audio.js_ - html5media_ - MediaElement.js_ .. _audio.js: https://kolber.github.io/audiojs/ .. _html5media: https://html5media.info/ .. _mediaelement.js: https://www.mediaelementjs.com/ .. _web-cors: Cross-Origin Resource Sharing (CORS) ------------------------------------ The ``web`` plugin's API can be used as a backend for an in-browser client. By default, browsers will only allow access from clients running on the same server as the API. (You will get an arcane error about ``XMLHttpRequest`` otherwise.) A technology called CORS_ lets you relax this restriction. If you want to use an in-browser client hosted elsewhere (or running from a different server on your machine), set the ``cors`` configuration option to the "origin" (protocol, host, and optional port number) where the client is served. Or set it to ``'*'`` to enable access from all origins. Note that there are security implications if you set the origin to ``'*'``, so please research this before using it. If the ``web`` server is behind a proxy that uses credentials, you might want to set the ``cors_supports_credentials`` configuration option to true to let in-browser clients log in. For example: :: web: host: 0.0.0.0 cors: 'http://example.com' .. _cors: https://en.wikipedia.org/wiki/Cross-origin_resource_sharing .. _reverse-proxy: Reverse Proxy Support --------------------- When the server is running behind a reverse proxy, you can tell the plugin to respect forwarded headers. Specifically, this can help when you host the plugin at a base URL other than the root ``/`` or when you use the proxy to handle secure connections. Enable the ``reverse_proxy`` configuration option if you do this. Technically, this option lets the proxy provide ``X-Script-Name`` and ``X-Scheme`` HTTP headers to control the plugin's the ``SCRIPT_NAME`` and its ``wsgi.url_scheme`` parameter. Here's a sample Nginx_ configuration that serves the web plugin under the /beets directory: :: location /beets { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Script-Name /beets; } .. _nginx: https://www.f5.com/products/nginx JSON API -------- ``GET /item/`` ~~~~~~~~~~~~~~ Responds with a list of all tracks in the beets library. :: { "items": [ { "id": 6, "title": "A Song", ... }, { "id": 12, "title": "Another Song", ... } ... ] } ``GET /item/6`` ~~~~~~~~~~~~~~~ Looks for an item with id *6* in the beets library and responds with its JSON representation. :: { "id": 6, "title": "A Song", ... } If there is no item with that id responds with a *404* status code. ``DELETE /item/6`` ~~~~~~~~~~~~~~~~~~ Removes the item with id *6* from the beets library. If the *?delete* query string is included, the matching file will be deleted from disk. Only allowed if ``readonly`` configuration option is set to ``no``. ``PATCH /item/6`` ~~~~~~~~~~~~~~~~~ Updates the item with id *6* and write the changes to the music file. The body should be a JSON object containing the changes to the object. Returns the updated JSON representation. :: { "id": 6, "title": "A Song", ... } Only allowed if ``readonly`` configuration option is set to ``no``. ``GET /item/6,12,13`` ~~~~~~~~~~~~~~~~~~~~~ Response with a list of tracks with the ids *6*, *12* and *13*. The format of the response is the same as for `GET /item/`_. It is *not guaranteed* that the response includes all the items requested. If a track is not found it is silently dropped from the response. This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all items of the list. ``GET /item/path/...`` ~~~~~~~~~~~~~~~~~~~~~~ Look for an item at the given absolute path on the server. If it corresponds to a track, return the track in the same format as ``/item/*``. If the server runs UNIX, you'll need to include an extra leading slash: ``http://localhost:8337/item/path//Users/beets/Music/Foo/Bar/Baz.mp3`` ``GET /item/query/querystring`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Returns a list of tracks matching the query. The *querystring* must be a valid query as described in :doc:`/reference/query`. :: { "results": [ { "id" : 6, "title": "A Song" }, { "id" : 12, "title": "Another Song" } ] } Path elements are joined as parts of a query. For example, ``/item/query/foo/bar`` will be converted to the query ``foo,bar``. To specify literal path separators in a query, use a backslash instead of a slash. This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all items returned by the query. ``GET /item/6/file`` ~~~~~~~~~~~~~~~~~~~~ Sends the media file for the track. If the item or its corresponding file do not exist a *404* status code is returned. Albums ~~~~~~ For albums, the following endpoints are provided: - ``GET /album/`` - ``GET /album/5`` - ``GET /album/5/art`` - ``DELETE /album/5`` - ``GET /album/5,7`` - ``DELETE /album/5,7`` - ``GET /album/query/querystring`` - ``DELETE /album/query/querystring`` The interface and response format is similar to the item API, except replacing the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/`` or ``/album/5,7``. In addition we can request the cover art of an album with ``GET /album/5/art``. You can also add the '?expand' flag to get the individual items of an album. ``DELETE`` is only allowed if ``readonly`` configuration option is set to ``no``. ``GET /stats`` ~~~~~~~~~~~~~~ Responds with the number of tracks and albums in the database. :: { "items": 5, "albums": 3 } .. _flask: https://flask.palletsprojects.com/en/stable/ ================================================ FILE: docs/plugins/zero.rst ================================================ Zero Plugin =========== The ``zero`` plugin allows you to null fields in files' metadata tags. Fields can be nulled unconditionally or conditioned on a pattern match. For example, the plugin can strip useless comments like "ripped by MyGreatRipper." The plugin can work in one of two modes: - ``fields``: A blacklist, where you choose the tags you want to remove (used by default). - ``keep_fields``: A whitelist, where you instead specify the tags you want to keep. To use the ``zero`` plugin, enable the plugin in your configuration (see :ref:`using-plugins`). Configuration ------------- Make a ``zero:`` section in your configuration file. You can specify the fields to nullify and the conditions for nullifying them: - Set ``auto`` to ``yes`` to null fields automatically on import. Default: ``yes``. - Set ``fields`` to a whitespace-separated list of fields to remove. You can get the list of all available fields by running ``beet fields``. In addition, the ``images`` field allows you to remove any images embedded in the media file. - Set ``keep_fields`` to *invert* the logic of the plugin. Only these fields will be kept; other fields will be removed. Remember to set only ``fields`` or ``keep_fields``---not both! - To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. - Set ``omit_single_disc`` to ``True`` to omit writing the ``disc`` number and the ``disctotal`` number for albums with a single disc (``disctotal == 1``). - By default this plugin only affects files' tags; the beets database is left unchanged. To update the tags in the database, set the ``update_database`` option to true. For example: :: zero: fields: month day genre genres comments comments: [EAC, LAME, from.+collection, 'ripped by'] genres: [rnb, 'power metal'] update_database: true If a custom pattern is not defined for a given field, the field will be nulled unconditionally. Note that the plugin currently does not zero fields when importing "as-is". Manually Triggering Zero ------------------------ You can also type ``beet zero [QUERY]`` to manually invoke the plugin on music in your library. Preserving Album Art -------------------- If you use the ``keep_fields`` option, the plugin will remove embedded album art from files' tags unless you tell it not to. To keep the album art, include the special field ``images`` in the list. For example: :: zero: keep_fields: title artist album year track genre genres images ================================================ FILE: docs/reference/cli.rst ================================================ Command-Line Interface ====================== .. only:: man SYNOPSIS -------- | **beet** [*args*...] *command* [*args*...] | **beet help** *command* .. only:: html **beet** is the command-line interface to beets. You invoke beets by specifying a *command*, like so:: beet COMMAND [ARGS...] The rest of this document describes the available commands. If you ever need a quick list of what's available, just type ``beet help`` or ``beet help COMMAND`` for help with a specific command. Beets also offers shell completion. For bash, see the `completion`_ command; for zsh, see the accompanying `completion script`_ for the ``beet`` command. Commands -------- .. only:: html Here are the built-in commands available in beets: .. contents:: :local: :depth: 1 Also be sure to see the :ref:`global flags <global-flags>`. .. _import-cmd: import ~~~~~~ :: beet import [-CWAPRqst] [-l LOGPATH] PATH... beet import [options] -L QUERY Add music to your library, attempting to get correct tags for it from MusicBrainz. Point the command at some music: directories, single files, or compressed archives. The music will be copied to a configurable directory structure and added to a library database. The command is interactive and will try to get you to verify MusicBrainz tags that it thinks are suspect. See the :doc:`autotagging guide </guides/tagger>` for detail on how to use the interactive tag-correction flow. Directories passed to the import command can contain either a single album or many, in which case the leaf directories will be considered albums (the latter case is true of typical Artist/Album organizations and many people's "downloads" folders). The path can also be a single song or an archive. Beets supports ``zip`` and ``tar`` archives out of the box. To extract ``rar`` files, install the rarfile_ package and the ``unrar`` command. To extract ``7z`` files, install the py7zr_ package. Optional command flags: - By default, the command copies files to your library directory and updates the ID3 tags on your music. In order to move the files, instead of copying, use the ``-m`` (move) option. If you'd like to leave your music files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags) options. You can also disable this behavior by default in the configuration file (below). - Also, you can disable the autotagging behavior entirely using ``-A`` (don't autotag)---then your music will be imported with its existing metadata. - During a long tagging import, it can be useful to keep track of albums that weren't tagged successfully---either because they're not in the MusicBrainz database or because something's wrong with the files. Use the ``-l`` option to specify a filename to log every time you skip an album or import it "as-is" or an album gets skipped as a duplicate. You can later review the file manually or import skipped paths from the logfile automatically by using the ``--from-logfile LOGFILE`` argument. - Relatedly, the ``-q`` (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode performs a fallback action that can be configured using the ``quiet_fallback`` configuration or ``--quiet-fallback`` CLI option. By default it pessimistically skips the file. Alternatively, it can be used as is, by configuring ``asis``. - Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by a crash). If you want to skip this prompt, you can say "yes" automatically by providing ``-p`` or "no" using ``-P``. The resuming feature can be disabled by default using a configuration option (see below). - If you want to import only the *new* stuff from a directory, use the ``-i`` option to run an *incremental* import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. This is useful if you have an "incoming" directory that you periodically add things to. To get this to work correctly, you'll need to use an incremental import *every time* you run an import on the directory in question---including the first time, when no subdirectories will be skipped. So consider enabling the ``incremental`` configuration option. - If you don't want to record skipped files during an *incremental* import, use the ``--incremental-skip-later`` flag which corresponds to the ``incremental_skip_later`` configuration option. Setting the flag prevents beets from persisting skip decisions during a non-interactive import so that a user can make a decision regarding previously skipped files during a subsequent interactive import run. To record skipped files during incremental import explicitly, use the ``--noincremental-skip-later`` option. - When beets applies metadata to your music, it will retain the value of any existing tags that weren't overwritten, and import them into the database. You may prefer to only use existing metadata for finding matches, and to erase it completely when new metadata is applied. You can enforce this behavior with the ``--from-scratch`` option, or the ``from_scratch`` configuration option. - By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the ``-t`` (for *timid*) option. - The importer typically works in a whole-album-at-a-time mode. If you instead want to import individual, non-album tracks, use the *singleton* mode by supplying the ``-s`` option. - If you have an album that's split across several directories under a common top directory, use the ``--flat`` option. This takes all the music files under the directory (recursively) and treats them as a single large album instead of as one album per directory. This can help with your more stubborn multi-disc albums. - Similarly, if you have one directory that contains multiple albums, use the ``--group-albums`` option to split the files based on their metadata before matching them as separate albums. - If you want to preview which files would be imported, use the ``--pretend`` option. If set, beets will just print a list of files that it would otherwise import. - If you already have a metadata backend ID that matches the items to be imported, you can instruct beets to restrict the search to that ID instead of searching for other candidates by using the ``--search-id SEARCH_ID`` option. Multiple IDs can be specified by simply repeating the option several times. - You can supply ``--set field=value`` to assign ``field`` to ``value`` on import. Values support the same template syntax as beets' :doc:`path formats <pathformat>`. These assignments will merge with (and possibly override) the :ref:`set_fields` configuration dictionary. You can use the option multiple times on the command line, like so: .. code-block:: sh beet import --set genres="Alternative Rock" --set mood="emotional" .. _py7zr: https://pypi.org/project/py7zr/ .. _rarfile: https://pypi.org/project/rarfile/ .. only:: html .. _reimport: Reimporting ^^^^^^^^^^^ The ``import`` command can also be used to "reimport" music that you've already added to your library. This is useful when you change your mind about some selections you made during the initial import, or if you prefer to import everything "as-is" and then correct tags later. Just point the ``beet import`` command at a directory of files that are already catalogged in your library. Beets will automatically detect this situation and avoid duplicating any items. In this situation, the "copy files" option (``-c``/``-C`` on the command line or ``copy`` in the config file) has slightly different behavior: it causes files to be *moved*, rather than duplicated, if they're already in your library. (The same is true, of course, if ``move`` is enabled.) That is, your directory structure will be updated to reflect the new tags if copying is enabled; you never end up with two copies of the file. The ``-L`` (``--library``) flag is also useful for retagging. Instead of listing paths you want to import on the command line, specify a :doc:`query string <query>` that matches items from your library. In this case, the ``-s`` (singleton) flag controls whether the query matches individual items or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` Note that, if you just want to update your files' tags according to changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a better choice. Reimporting uses the full matching machinery to guess metadata matches; ``mbsync`` just relies on MusicBrainz IDs. .. _list-cmd: list ~~~~ :: beet list [-apf] QUERY :doc:`Queries <query>` the database for music. Want to search for "Gronlandic Edit" by of Montreal? Try ``beet list gronlandic``. Maybe you want to see everything released in 2009 with "vegetables" in the title? Try ``beet list year:2009 title:vegetables``. You can also specify the sort order. (Read more in :doc:`query`.) You can use the ``-a`` switch to search for albums instead of individual items. In this case, the queries you use are restricted to album-level fields: for example, you can search for ``year:1969`` but query parts for item-level fields like ``title:foo`` will be ignored. Remember that ``artist`` is an item-level field; ``albumartist`` is the corresponding album field. The ``-p`` option makes beets print out filenames of matched items, which might be useful for piping into other Unix commands (such as `xargs <https://en.wikipedia.org/wiki/Xargs>`__). Similarly, the ``-f`` option lets you specify a specific format with which to print every album or track. This uses the same template syntax as beets' :doc:`path formats <pathformat>`. For example, the command ``beet ls -af '$album: $albumtotal' beatles`` prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. .. _remove-cmd: remove ~~~~~~ :: beet remove [-adf] QUERY Remove music from your library. This command uses the same :doc:`query <query>` syntax as the ``list`` command. By default, it just removes entries from the library database; it doesn't touch the files on disk. To actually delete the files, use the ``-d`` flag. When the ``-a`` flag is given, the command operates on albums instead of individual tracks. When you run the ``remove`` command, it prints a list of all affected items in the library and asks for your permission before removing them. You can then choose to abort (type ``n``), confirm (``y``), or interactively choose some of the items (``s``). In the latter case, the command will prompt you for every matching item or album and invite you to type ``y`` to remove the item/album, ``n`` to keep it or ``q`` to exit and only remove the items/albums selected up to this point. This option lets you choose precisely which tracks/albums to remove without spending too much time to carefully craft a query. If you do not want to be prompted at all, use the ``-f`` option. .. _modify-cmd: modify ~~~~~~ :: beet modify [-IMWay] [-f FORMAT] QUERY [FIELD=VALUE...] [FIELD!...] Change the metadata for items or albums in the database. Supply a :doc:`query <query>` matching the things you want to change and a series of ``field=value`` pairs. For example, ``beet modify genius of love artist="Tom Tom Club"`` will change the artist for the track "Genius of Love." To remove fields (which is only possible for flexible attributes), follow a field name with an exclamation point: ``field!``. Values can also be *templates*, using the same syntax as :doc:`path formats <pathformat>`. For example, ``beet modify artist='$artist_sort'`` will copy the artist sort name into the artist field for all your tracks, and ``beet modify title='$track $title'`` will add track numbers to their title metadata. To adjust a multi-valued field, such as ``genres``, separate the values with |semicolon_space|. For example, ``beet modify genres="rock; pop"``. The ``-a`` option changes to querying album fields instead of track fields and also enables to operate on albums in addition to the individual tracks. Without this flag, the command will only change *track-level* data, even if all the tracks belong to the same album. If you want to change an *album-level* field, such as ``year`` or ``albumartist``, you'll want to use the ``-a`` flag to avoid a confusing situation where the data for individual tracks conflicts with the data for the whole album. Modifications issued using ``-a`` by default cascade to individual tracks. To prevent this behavior, use ``-I``/``--noinherit``. Items will automatically be moved around when necessary if they're in your library directory, but you can disable that with ``-M``. Tags will be written to the files according to the settings you have for imports, but these can be overridden with ``-w`` (write tags, the default) and ``-W`` (don't write tags). When you run the ``modify`` command, it prints a list of all affected items in the library and asks for your permission before making any changes. You can then choose to abort the change (type ``n``), confirm (``y``), or interactively choose some of the items (``s``). In the latter case, the command will prompt you for every matching item or album and invite you to type ``y`` to apply the changes, ``n`` to discard them or ``q`` to exit and apply the selected changes. This option lets you choose precisely which data to change without spending too much time to carefully craft a query. To skip the prompts entirely, use the ``-y`` option. .. _move-cmd: move ~~~~ :: beet move [-capt] [-d DIR] QUERY Move or copy items in your library. This command, by default, acts as a library consolidator: items matching the query are renamed into your library directory structure. By specifying a destination directory with ``-d`` manually, you can move items matching a query anywhere in your filesystem. The ``-c`` option copies files instead of moving them. As with other commands, the ``-a`` option matches albums instead of items. The ``-e`` flag (for "export") copies files without changing the database. To perform a "dry run", just use the ``-p`` (for "pretend") flag. This will show you a list of files that would be moved but won't actually change anything on disk. The ``-t`` option sets the timid mode which will ask again before really moving or copying the files. .. _update-cmd: update ~~~~~~ :: beet update [-F] FIELD [-e] EXCLUDE_FIELD [-aMp] QUERY Update the library (and, by default, move files) to reflect out-of-band metadata changes and file deletions. This will scan all the matched files and read their tags, populating the database with the new values. By default, files will be renamed according to their new metadata; disable this with ``-M``. Beets will skip files if their modification times have not changed, so any out-of-band metadata changes must also update these for ``beet update`` to recognise that the files have been edited. To perform a "dry run" of an update, just use the ``-p`` (for "pretend") flag. This will show you all the proposed changes but won't actually change anything on disk. By default, all the changed metadata will be populated back to the database. If you only want certain fields to be written, specify them with the ``-F`` flags (which can be used multiple times). Alternatively, specify fields to *not* write with ``-e`` flags (which can be used multiple times). For the list of supported fields, please see ``beet fields``. When an updated track is part of an album, the album-level fields of *all* tracks from the album are also updated. (Specifically, the command copies album-level data from the first track on the album and applies it to the rest of the tracks.) This means that, if album-level fields aren't identical within an album, some changes shown by the ``update`` command may be overridden by data from other tracks on the same album. This means that running the ``update`` command multiple times may show the same changes being applied. .. _write-cmd: write ~~~~~ :: beet write [-pf] [QUERY] Write metadata from the database into files' tags. When you make changes to the metadata stored in beets' library database (during import or with the :ref:`modify-cmd` command, for example), you often have the option of storing changes only in the database, leaving your files untouched. The ``write`` command lets you later change your mind and write the contents of the database into the files. By default, this writes the changes only if there is a difference between the database and the tags in the file. You can think of this command as the opposite of :ref:`update-cmd`. The ``-p`` option previews metadata changes without actually applying them. The ``-f`` option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file. .. _stats-cmd: stats ~~~~~ :: beet stats [-e] [QUERY] Show some statistics on your entire library (if you don't provide a :doc:`query <query>`) or the matched items (if you do). By default, the command calculates file sizes using their bitrate and duration. The ``-e`` (``--exact``) option reads the exact sizes of each file (but is slower). The exact mode also outputs the exact duration in seconds. .. _fields-cmd: fields ~~~~~~ :: beet fields Show the item and album metadata fields available for use in :doc:`query` and :doc:`pathformat`. The listing includes any template fields provided by plugins and any flexible attributes you've manually assigned to your items and albums. .. _config-cmd: config ~~~~~~ :: beet config [-pdc] beet config -e Show or edit the user configuration. This command does one of three things: - With no options, print a YAML representation of the current user configuration. With the ``--default`` option, beets' default options are also included in the dump. - The ``--path`` option instead shows the path to your configuration file. This can be combined with the ``--default`` flag to show where beets keeps its internal defaults. - By default, sensitive information like passwords is removed when dumping the configuration. The ``--clear`` option includes this sensitive data. - With the ``--edit`` option, beets attempts to open your config file for editing. It first tries the ``$EDITOR`` environment variable, followed by ``$EDITOR`` and then a fallback option depending on your platform: ``open`` on OS X, ``xdg-open`` on Unix, and direct invocation on Windows. .. _global-flags: Global Flags ------------ Beets has a few "global" flags that affect all commands. These must appear between the executable name (``beet``) and the command---for example, ``beet -v import ...``. - ``-l LIBPATH``: specify the library database file to use. - ``-d DIRECTORY``: specify the library root directory. - ``-v``: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. You can use it twice, as in ``-vv``, to make beets even more verbose. - ``-c FILE``: read a specified YAML :doc:`configuration file <config>`. This configuration works as an overlay: rather than replacing your normal configuration options entirely, the two are merged. Any individual options set in this config file will override the corresponding settings in your base configuration. - ``-p plugins``: specify a comma-separated list of plugins to enable. If specified, the plugin list in your configuration is ignored. The long form of this argument also allows specifying no plugins, effectively disabling all plugins: ``--plugins=``. - ``-P plugins``: specify a comma-separated list of plugins to disable in a specific beets run. This will overwrite ``-p`` if used with it. To disable all plugins, use ``--plugins=`` instead. Beets also uses the ``BEETSDIR`` environment variable to look for configuration and data. .. _completion: Shell Completion ---------------- Beets includes support for shell command completion. The command ``beet completion`` prints out a bash_ 3.2 script; to enable completion put a line like this into your ``.bashrc`` or similar file: :: eval "$(beet completion)" Or, to avoid slowing down your shell startup time, you can pipe the ``beet completion`` output to a file and source that instead. You will also need to source the bash-completion_ script, which is probably available via your package manager. On OS X, you can install it via Homebrew with ``brew install bash-completion``; Homebrew will give you instructions for sourcing the script. .. _bash: https://www.gnu.org/software/bash/ .. _bash-completion: https://github.com/scop/bash-completion The completion script suggests names of subcommands and (after typing ``-``) options of the given command. If you are using a command that accepts a query, the script will also complete field names. :: beet list ar[TAB] # artist: artist_credit: artist_sort: artpath: beet list artp[TAB] beet list artpath\: (Don't worry about the slash in front of the colon: this is a escape sequence for the shell and won't be seen by beets.) Completion of plugin commands only works for those plugins that were enabled when running ``beet completion``. If you add a plugin later on you will want to re-generate the script. zsh ~~~ If you use zsh, take a look at the included `completion script`_. The script should be placed in a directory that is part of your ``fpath``, and ``not`` sourced in your ``.zshrc``. Running ``echo $fpath`` will give you a list of valid directories. Another approach is to use zsh's bash completion compatibility. This snippet defines some bash-specific functions to make this work without errors: :: autoload bashcompinit bashcompinit _get_comp_words_by_ref() { :; } compopt() { :; } _filedir() { :; } eval "$(beet completion)" .. _completion script: https://github.com/beetbox/beets/blob/master/extra/_beet .. only:: man See Also -------- ``https://beets.readthedocs.org/`` :manpage:`beetsconfig(5)` ================================================ FILE: docs/reference/config.rst ================================================ Configuration ============= Beets has an extensive configuration system that lets you customize nearly every aspect of its operation. To configure beets, you create a file called ``config.yaml``. The location of the file depends on your platform (type ``beet config -p`` to see the path on your system): - On Unix-like OSes, write ``~/.config/beets/config.yaml``. - On Windows, use ``%APPDATA%\beets\config.yaml``. This is usually in a directory like ``C:\Users\You\AppData\Roaming``. - On OS X, you can use either the Unix location or ``~/Library/Application Support/beets/config.yaml``. You can launch your text editor to create or update your configuration by typing ``beet config -e``. (See the :ref:`config-cmd` command for details.) It is also possible to customize the location of the configuration file and even use multiple layers of configuration. See `Configuration Location`_, below. The config file uses YAML_ syntax. You can use the full power of YAML, but most configuration options are simple key/value pairs. This means your config file will look like this: :: option: value another_option: foo bigger_option: key: value foo: bar In YAML, you will need to use spaces (not tabs!) to indent some lines. If you have questions about more sophisticated syntax, take a look at the YAML_ documentation. .. _yaml: https://yaml.org/ The rest of this page enumerates the dizzying litany of configuration options available in beets. You might also want to see an :ref:`example <config-example>`. .. contents:: :local: :depth: 2 Global Options -------------- These options control beets' global operation. library ~~~~~~~ Path to the beets library file. By default, beets will use a file called ``library.db`` alongside your configuration file. directory ~~~~~~~~~ The directory to which files will be copied/moved when adding them to the library. Defaults to a folder called ``Music`` in your home directory. .. _plugins-config: plugins ~~~~~~~ A space-separated list of plugin module names to load. See :ref:`using-plugins`. include ~~~~~~~ A space-separated list of extra configuration files to include. Filenames are relative to the directory containing ``config.yaml``. pluginpath ~~~~~~~~~~ Directories to search for plugins. Each Python file or directory in a plugin path represents a plugin and should define a subclass of |BeetsPlugin|. A plugin can then be loaded by adding the plugin name to the ``plugins`` configuration. The plugin path can either be a single string or a list of strings---so, if you have multiple paths, format them as a YAML list like so: :: pluginpath: - /path/one - /path/two .. _ignore: ignore ~~~~~~ A list of glob patterns specifying file and directory names to be ignored when importing. By default, this consists of ``.*``, ``*~``, ``System Volume Information``, ``lost+found`` (i.e., beets ignores Unix-style hidden files, backup files, and directories that appears at the root of some Linux and Windows filesystems). .. _ignore_hidden: ignore_hidden ~~~~~~~~~~~~~ Either ``yes`` or ``no``; whether to ignore hidden files when importing. On Windows, the "Hidden" property of files is used to detect whether or not a file is hidden. On OS X, the file's "IsHidden" flag is used to detect whether or not a file is hidden. On both OS X and other platforms (excluding Windows), files (and directories) starting with a dot are detected as hidden files. .. _replace: replace ~~~~~~~ A set of regular expression/replacement pairs to be applied to all filenames created by beets. Typically, these replacements are used to avoid confusing problems or errors with the filesystem (for example, leading dots, which hide files on Unix, and trailing whitespace, which is illegal on Windows). To override these substitutions, specify a mapping from regular expression to replacement strings. For example, ``[xy]: z`` will make beets replace all instances of the characters ``x`` or ``y`` with the character ``z``. If you do change this value, be certain that you include at least enough substitutions to avoid causing errors on your operating system. Here are the default substitutions used by beets, which are sufficient to avoid unexpected behavior on all popular platforms: :: replace: '[\\/]': _ '^\.': _ '[\x00-\x1f]': _ '[<>:"\?\*\|]': _ '\.$': _ '\s+$': '' '^\s+': '' '^-': _ These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line removes the Windows "reserved characters" (useful even on Unix for compatibility with Windows-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. When replacements other than the defaults are used, it is possible that they will increase the length of the path. In the scenario where this leads to a conflict with the maximum filename length, the default replacements will be used to resolve the conflict and beets will display a warning. Note that paths might contain special characters such as typographical quotes (``“”``). With the configuration above, those will not be replaced as they don't match the typewriter quote (``"``). To also strip these special characters, you can either add them to the replacement list or use the :ref:`asciify-paths` configuration option below. .. _path-sep-replace: path_sep_replace ~~~~~~~~~~~~~~~~ A string that replaces the path separator (for example, the forward slash ``/`` on Linux and MacOS, and the backward slash ``\\`` on Windows) when generating filenames with beets. This option is related to :ref:`replace`, but is distinct from it for technical reasons. .. warning:: Changing this option is potentially dangerous. For example, setting it to the actual path separator could create directories in unexpected locations. Use caution when changing it and always try it out on a small number of files before applying it to your whole library. Default: ``_``. .. _asciify-paths: asciify_paths ~~~~~~~~~~~~~ Convert all non-ASCII characters in paths to ASCII equivalents. For example, if your path template for singletons is ``singletons/$title`` and the title of a track is "Café", then the track will be saved as ``singletons/Cafe.mp3``. The changes take place before applying the :ref:`replace` configuration and are roughly equivalent to wrapping all your path templates in the ``%asciify{}`` :ref:`template function <template-functions>`. This uses the `unidecode module <https://pypi.org/project/Unidecode>`__ which is language agnostic, so some characters may be transliterated from a different language than expected. For example, Japanese kanji will usually use their Chinese readings. Default: ``no``. .. _art-filename: art_filename ~~~~~~~~~~~~ When importing album art, the name of the file (without extension) where the cover art image should be placed. This is a template string, so you can use any of the syntax available to :doc:`/reference/pathformat`. Defaults to ``cover`` (i.e., images will be named ``cover.jpg`` or ``cover.png`` and placed in the album's directory). threaded ~~~~~~~~ Either ``yes`` or ``no``, indicating whether the autotagger should use multiple threads. This makes things substantially faster by overlapping work: for example, it can copy files for one album in parallel with looking up data in MusicBrainz for a different album. You may want to disable this when debugging problems with the autotagger. Defaults to ``yes``. .. _format_item: .. _list_format_item: format_item ~~~~~~~~~~~ Format to use when listing *individual items* with the :ref:`list-cmd` command and other commands that need to print out items. Defaults to ``$artist - $album - $title``. The ``-f`` command-line option overrides this setting. It used to be named ``list_format_item``. .. _format_album: .. _list_format_album: format_album ~~~~~~~~~~~~ Format to use when listing *albums* with :ref:`list-cmd` and other commands. Defaults to ``$albumartist - $album``. The ``-f`` command-line option overrides this setting. It used to be named ``list_format_album``. .. _sort_item: sort_item ~~~~~~~~~ Default sort order to use when fetching items from the database. Defaults to ``artist+ album+ disc+ track+``. Explicit sort orders override this default. .. _sort_album: sort_album ~~~~~~~~~~ Default sort order to use when fetching albums from the database. Defaults to ``albumartist+ album+``. Explicit sort orders override this default. .. _sort_case_insensitive: sort_case_insensitive ~~~~~~~~~~~~~~~~~~~~~ Either ``yes`` or ``no``, indicating whether the case should be ignored when sorting lexicographic fields. When set to ``no``, lower-case values will be placed after upper-case values (e.g., *Bar Qux foo*), while ``yes`` would result in the more expected *Bar foo Qux*. Default: ``yes``. .. _original_date: original_date ~~~~~~~~~~~~~ Either ``yes`` or ``no``, indicating whether matched albums should have their ``year``, ``month``, and ``day`` fields set to the release date of the *original* version of an album rather than the selected version of the release. That is, if this option is turned on, then ``year`` will always equal ``original_year`` and so on. Default: ``no``. .. _overwrite_null: overwrite_null ~~~~~~~~~~~~~~ This confusingly-named option indicates which fields have meaningful ``null`` values. If an album or track field is in the corresponding list, then an existing value for this field in an item in the database can be overwritten with ``null``. By default, however, ``null`` is interpreted as information about the field being unavailable, so it would not overwrite existing values. For example: :: overwrite_null: album: ["albumid"] track: ["title", "date"] .. _artist_credit: artist_credit ~~~~~~~~~~~~~ Either ``yes`` or ``no``, indicating whether matched tracks and albums should use the artist credit, rather than the artist. That is, if this option is turned on, then ``artist`` will contain the artist as credited on the release. .. _per_disc_numbering: per_disc_numbering ~~~~~~~~~~~~~~~~~~ A boolean controlling the track numbering style on multi-disc releases. By default (``per_disc_numbering: no``), tracks are numbered per-release, so the first track on the second disc has track number N+1 where N is the number of tracks on the first disc. If this ``per_disc_numbering`` is enabled, then the first (non-pregap) track on each disc always has track number 1. If you enable ``per_disc_numbering``, you will likely want to change your :ref:`path-format-config` also to include ``$disc`` before ``$track`` to make filenames sort correctly in album directories. For example, you might want to use a path format like this: :: paths: default: $albumartist/$album%aunique{}/$disc-$track $title When this option is off (the default), even "pregap" hidden tracks are numbered from one, not zero, so other track numbers may appear to be bumped up by one. When it is on, the pregap track for each disc can be numbered zero. .. _config-aunique: aunique ~~~~~~~ These options are used to generate a string that is guaranteed to be unique among all albums in the library who share the same set of keys. The defaults look like this: :: aunique: keys: albumartist album disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig bracket: '[]' See :ref:`aunique` for more details. .. _config-sunique: sunique ~~~~~~~ Like :ref:`config-aunique` above for albums, these options control the generation of a unique string to disambiguate *singletons* that share similar metadata. The defaults look like this: :: sunique: keys: artist title disambiguators: year trackdisambig bracket: '[]' See :ref:`sunique` for more details. .. _terminal_encoding: terminal_encoding ~~~~~~~~~~~~~~~~~ The text encoding, as `known to Python <https://docs.python.org/3/library/codecs.html#standard-encodings>`__, to use for messages printed to the standard output. It's also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. .. _clutter: clutter ~~~~~~~ When beets imports all the files in a directory, it tries to remove the directory if it's empty. A directory is considered empty if it only contains files whose names match the glob patterns in ``clutter``, which should be a list of strings. The default list consists of "Thumbs.DB" and ".DS_Store". The importer only removes recursively searched subdirectories---the top-level directory you specify on the command line is never deleted. .. _max_filename_length: max_filename_length ~~~~~~~~~~~~~~~~~~~ Set the maximum number of characters in a filename, after which names will be truncated. By default, beets tries to ask the filesystem for the correct maximum. .. _id3v23: id3v23 ~~~~~~ By default, beets writes MP3 tags using the ID3v2.4 standard, the latest version of ID3. Enable this option to instead use the older ID3v2.3 standard, which is preferred by certain older software such as Windows Media Player. .. _va_name: va_name ~~~~~~~ Sets the albumartist for various-artist compilations. Defaults to ``'Various Artists'`` (the MusicBrainz standard). Affects other sources, such as :doc:`/plugins/discogs`, too. .. _ui_options: UI Options ---------- The options that allow for customization of the visual appearance of the console interface. color ~~~~~ Either ``yes`` or ``no``; whether to use color in console output. Turn this off if your terminal doesn't support ANSI colors. .. _colors: colors ~~~~~~ The colors that are used throughout the user interface. These are only used if the ``color`` option is set to ``yes``. See the default configuration: .. code-block:: yaml ui: colors: text_success: ['bold', 'green'] text_warning: ['bold', 'yellow'] text_error: ['bold', 'red'] text_highlight: ['bold', 'red'] text_highlight_minor: ['white'] action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New colors after UI overhaul text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] Available attributes: Foreground colors ``black``, ``red``, ``green``, ``yellow``, ``blue``, ``magenta``, ``cyan``, ``white``, ``bright_black``, ``bright_red``, ``bright_green``, ``bright_yellow``, ``bright_blue``, ``bright_magenta``, ``bright_cyan``, ``bright_white`` Background colors ``bg_black``, ``bg_red``, ``bg_green``, ``bg_yellow``, ``bg_blue``, ``bg_magenta``, ``bg_cyan``, ``bg_white``, ``bg_bright_black``, ``bg_bright_red``, ``bg_bright_green``, ``bg_bright_yellow``, ``bg_bright_blue``, ``bg_bright_magenta``, ``bg_bright_cyan``, ``bg_bright_white`` Text styles ``normal``, ``bold``, ``faint``, ``italic``, ``underline``, ``blink_slow``, ``blink_rapid``, ``inverse``, ``conceal``, ``crossed_out`` terminal_width ~~~~~~~~~~~~~~ Controls line wrapping on non-Unix systems. On Unix systems, the width of the terminal is detected automatically. If this fails, or on non-Unix systems, the specified value is used as a fallback. Defaults to ``80`` characters: .. code-block:: yaml ui: terminal_width: 80 length_diff_thresh ~~~~~~~~~~~~~~~~~~ Beets compares the length of the imported track with the length the metadata source provides. If any tracks differ by at least ``length_diff_thresh`` seconds, they will be colored with ``text_highlight``. Below this threshold, different track lengths are colored with ``text_highlight_minor``. ``length_diff_thresh`` does not impact which releases are selected in autotagger matching or distance score calculation (see :ref:`match-config`, ``distance_weights`` and :ref:`colors`): .. code-block:: yaml ui: length_diff_thresh: 10.0 import ~~~~~~ When importing, beets will read several options to configure the visuals of the import dialogue. There are two layouts controlling how horizontal space and line wrapping is dealt with: ``column`` and ``newline``. The indentation of the respective elements of the import UI can also be configured. For example setting ``2`` for ``match_header`` will indent the very first block of a proposed match by two characters in the terminal: .. code-block:: yaml ui: import: indentation: match_header: 2 match_details: 2 match_tracklist: 5 layout: column Importer Options ---------------- The options that control the :ref:`import-cmd` command are indented under the ``import:`` key. For example, you might have a section in your configuration file that looks like this: :: import: write: yes copy: yes resume: no These options are available in this section: .. _config-import-write: write ~~~~~ Either ``yes`` or ``no``, controlling whether metadata (e.g., ID3) tags are written to files when using ``beet import``. Defaults to ``yes``. The ``-w`` and ``-W`` command-line options override this setting. .. _config-import-copy: copy ~~~~ Either ``yes`` or ``no``, indicating whether to **copy** files into the library directory when using ``beet import``. Defaults to ``yes``. Can be overridden with the ``-c`` and ``-C`` command-line options. The option is ignored if ``move`` is enabled (i.e., beets can move or copy files but it doesn't make sense to do both). .. _config-import-move: move ~~~~ Either ``yes`` or ``no``, indicating whether to **move** files into the library directory when using ``beet import``. Defaults to ``no``. The effect is similar to the ``copy`` option but you end up with only one copy of the imported file. ("Moving" works even across filesystems; if necessary, beets will copy and then delete when a simple rename is impossible.) Moving files can be risky—it's a good idea to keep a backup in case beets doesn't do what you expect with your files. This option *overrides* ``copy``, so enabling it will always move (and not copy) files. The ``-c`` switch to the ``beet import`` command, however, still takes precedence. .. _link: link ~~~~ Either ``yes`` or ``no``, indicating whether to use symbolic links instead of moving or copying files. (It conflicts with the ``move``, ``copy`` and ``hardlink`` options.) Defaults to ``no``. This option only works on platforms that support symbolic links: i.e., Unixes. It will fail on Windows. It's likely that you'll also want to set ``write`` to ``no`` if you use this option to preserve the metadata on the linked files. .. _hardlink: hardlink ~~~~~~~~ Either ``yes`` or ``no``, indicating whether to use hard links instead of moving, copying, or symlinking files. (It conflicts with the ``move``, ``copy``, and ``link`` options.) Defaults to ``no``. As with symbolic links (see :ref:`link`, above), this will not work on Windows and you will want to set ``write`` to ``no``. Otherwise, metadata on the original file will be modified. .. _reflink: reflink ~~~~~~~ Either ``yes``, ``no``, or ``auto``, indicating whether to use copy-on-write `file clones`_ (a.k.a. "reflinks") instead of copying or moving files. The ``auto`` option uses reflinks when possible and falls back to plain copying when necessary. Defaults to ``no``. This kind of clone is only available on certain filesystems: for example, btrfs and APFS. For more details on filesystem support, see the pyreflink_ documentation. Note that you need to install ``pyreflink``, either through ``python -m pip install beets[reflink]`` or ``python -m pip install reflink``. The option is ignored if ``move`` is enabled (i.e., beets can move or copy files but it doesn't make sense to do both). .. _file clones: https://en.wikipedia.org/wiki/Copy-on-write .. _pyreflink: https://reflink.readthedocs.io/en/latest/ resume ~~~~~~ Either ``yes``, ``no``, or ``ask``. Controls whether interrupted imports should be resumed. "Yes" means that imports are always resumed when possible; "no" means resuming is disabled entirely; "ask" (the default) means that the user should be prompted when resuming is possible. The ``-p`` and ``-P`` flags correspond to the "yes" and "no" settings and override this option. .. _incremental: incremental ~~~~~~~~~~~ Either ``yes`` or ``no``, controlling whether imported directories are recorded and whether these recorded directories are skipped. This corresponds to the ``-i`` flag to ``beet import``. .. _incremental_skip_later: incremental_skip_later ~~~~~~~~~~~~~~~~~~~~~~ Either ``yes`` or ``no``, controlling whether skipped directories are recorded in the incremental list. When set to ``yes``, skipped directories won't be recorded, and beets will try to import them again later. When set to ``no``, skipped directories will be recorded, and skipped later. Defaults to ``no``. .. _from_scratch: from_scratch ~~~~~~~~~~~~ Either ``yes`` or ``no`` (default), controlling whether existing metadata is discarded when a match is applied. This corresponds to the ``--from-scratch`` flag to ``beet import``. .. _quiet: quiet ~~~~~ Either ``yes`` or ``no`` (default), controlling whether to ask for a manual decision from the user when the importer is unsure how to proceed. This corresponds to the ``--quiet`` flag to ``beet import``. .. _quiet_fallback: quiet_fallback ~~~~~~~~~~~~~~ Either ``skip`` (default) or ``asis``, specifying what should happen in quiet mode (see the ``-q`` flag to ``import``, above) when there is no strong recommendation. .. _none_rec_action: none_rec_action ~~~~~~~~~~~~~~~ Either ``ask`` (default), ``asis`` or ``skip``. Specifies what should happen during an interactive import session when there is no recommendation. Useful when you are only interested in processing medium and strong recommendations interactively. timid ~~~~~ Either ``yes`` or ``no``, controlling whether the importer runs in *timid* mode, in which it asks for confirmation on every autotagging match, even the ones that seem very close. Defaults to ``no``. The ``-t`` command-line flag controls the same setting. .. _import_log: log ~~~ Specifies a filename where the importer's log should be kept. By default, no log is written. This can be overridden with the ``-l`` flag to ``import``. .. _default_action: default_action ~~~~~~~~~~~~~~ One of ``apply``, ``skip``, ``asis``, or ``none``, indicating which option should be the *default* when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is ``apply``. .. _languages: languages ~~~~~~~~~ A list of locale names to search for preferred aliases. For example, setting this to ``en`` uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" instead of the Cyrillic script for the composer's name when tagging from MusicBrainz. You can use a space-separated list of language abbreviations, like ``en jp es``, to specify a preference order. Defaults to an empty list, meaning that no language is preferred. The alias is used for artist name, track title, release group title and album title. .. _ignored_alias_types: ignored_alias_types ~~~~~~~~~~~~~~~~~~~ A list of alias types to be ignored when importing new items. See the ``MusicBrainz Documentation`` for more information on aliases. .._MusicBrainz Documentation: https://musicbrainz.org/doc/Aliases .. _detail: detail ~~~~~~ Whether the importer UI should show detailed information about each match it finds. When enabled, this mode prints out the title of every track, regardless of whether it matches the original metadata. (The default behavior only shows changes.) Default: ``no``. .. _group_albums: group_albums ~~~~~~~~~~~~ By default, the beets importer groups tracks into albums based on the directories they reside in. This option instead uses files' metadata to partition albums. Enable this option if you have directories that contain tracks from many albums mixed together. The ``--group-albums`` or ``-g`` option to the :ref:`import-cmd` command is equivalent, and the *G* interactive option invokes the same workflow. Default: ``no``. .. _autotag: autotag ~~~~~~~ By default, the beets importer always attempts to autotag new music. If most of your collection consists of obscure music, you may be interested in disabling autotagging by setting this option to ``no``. (You can re-enable it with the ``-a`` flag to the :ref:`import-cmd` command.) Default: ``yes``. .. _duplicate_keys: duplicate_keys ~~~~~~~~~~~~~~ The fields used to find duplicates when importing. There are two sub-values here: ``album`` and ``item``. Each one is a list of field names; if an existing object (album or item) in the library matches the new object on all of these fields, the importer will consider it a duplicate. Default: :: album: albumartist album item: artist title .. _duplicate_action: duplicate_action ~~~~~~~~~~~~~~~~ Either ``skip``, ``keep``, ``remove``, ``merge`` or ``ask``. Controls how duplicates are treated in import task. "skip" means that new item(album or track) will be skipped; "keep" means keep both old and new items; "remove" means remove old item; "merge" means merge into one album; "ask" means the user should be prompted for the action each time. The default is ``ask``. .. _duplicate_verbose_prompt: duplicate_verbose_prompt ~~~~~~~~~~~~~~~~~~~~~~~~ Usually when duplicates are detected during import, information about the existing and the newly imported album is summarized. Enabling this option also lists details on individual tracks. The :ref:`format_item setting <format_item>` is applied, which would, considering the default, look like this: .. code-block:: console This item is already in the library! Old: 1 items, MP3, 320kbps, 5:56, 13.6 MiB Artist Name - Album Name - Third Track Title New: 2 items, MP3, 320kbps, 7:18, 17.1 MiB Artist Name - Album Name - First Track Title Artist Name - Album Name - Second Track Title [S]kip new, Keep all, Remove old, Merge all? Default: ``no``. .. _bell: bell ~~~~ Ring the terminal bell to get your attention when the importer needs your input. Default: ``no``. .. _set_fields: set_fields ~~~~~~~~~~ A dictionary indicating fields to set to values for newly imported music. Here's an example: .. code-block:: yaml set_fields: genres: To Listen collection: Unordered Other field/value pairs supplied via the ``--set`` option on the command-line override any settings here for fields with the same name. Values support the same template syntax as beets' :doc:`path formats <pathformat>`. Fields are set on both the album and each individual track of the album. Fields are persisted to the media files of each track. Default: ``{}`` (empty). .. _singleton_album_disambig: singleton_album_disambig ~~~~~~~~~~~~~~~~~~~~~~~~ During singleton imports and if the metadata source provides it, album names are appended to the disambiguation string of matching track candidates. For example: ``The Artist - The Title (Discogs, Index 3, Track B1, [The Album]``. This feature is currently supported by the :doc:`/plugins/discogs` and the :doc:`/plugins/spotify`. Default: ``yes``. .. _match-config: Autotagger Matching Options --------------------------- You can configure some aspects of the logic beets uses when automatically matching MusicBrainz results under the ``match:`` section. To control how *tolerant* the autotagger is of differences, use the ``strong_rec_thresh`` option, which reflects the distance threshold below which beets will make a "strong recommendation" that the metadata be used. Strong recommendations are accepted automatically (except in "timid" mode), so you can use this to make beets ask your opinion more or less often. The threshold is a *distance* value between 0.0 and 1.0, so you can think of it as the opposite of a *similarity* value. For example, if you want to automatically accept any matches above 90% similarity, use: :: match: strong_rec_thresh: 0.10 The default strong recommendation threshold is 0.04. The ``medium_rec_thresh`` and ``rec_gap_thresh`` options work similarly. When a match is below the *medium* recommendation threshold or the distance between it and the next-best match is above the *gap* threshold, the importer will suggest that match but not automatically confirm it. Otherwise, you'll see a list of options to choose from. .. _distance-weights: distance_weights ~~~~~~~~~~~~~~~~ The ``distance_weights`` option allows you to customize how much each field contributes to the overall distance score when matching albums and tracks. Higher weights mean that differences in that field are penalized more heavily, making them more important in the matching decision. The defaults are: .. code-block:: yaml match: distance_weights: data_source: 2.0 artist: 3.0 album: 3.0 media: 1.0 mediums: 1.0 year: 1.0 country: 0.5 label: 0.5 catalognum: 0.5 albumdisambig: 0.5 album_id: 5.0 tracks: 2.0 missing_tracks: 0.9 unmatched_tracks: 0.6 track_title: 3.0 track_artist: 2.0 track_index: 1.0 track_length: 2.0 track_id: 5.0 medium: 1.0 For example, if you don't care as much about matching the exact release year, you can reduce its weight: .. code-block:: yaml match: distance_weights: year: 0.1 You only need to specify the fields you want to override; unspecified fields keep their default weights. .. _max_rec: max_rec ~~~~~~~ As mentioned above, autotagger matches have *recommendations* that control how the UI behaves for a certain quality of match. The recommendation for a certain match is based on the overall distance calculation. But you can also control the recommendation when a specific distance penalty is applied by defining *maximum* recommendations for each field: To define maxima, use keys under ``max_rec:`` in the ``match`` section. The defaults are "medium" for missing and unmatched tracks and "strong" (i.e., no maximum) for everything else: :: match: max_rec: missing_tracks: medium unmatched_tracks: medium If a recommendation is higher than the configured maximum and the indicated penalty is applied, the recommendation is downgraded. The setting for each field can be one of ``none``, ``low``, ``medium`` or ``strong``. When the maximum recommendation is ``strong``, no "downgrading" occurs. The available penalty names here are: - data_source - artist - album - media - mediums - year - country - label - catalognum - albumdisambig - album_id - tracks - missing_tracks - unmatched_tracks - track_title - track_artist - track_index - track_length - track_id .. _preferred: preferred ~~~~~~~~~ In addition to comparing the tagged metadata with the match metadata for similarity, you can also specify an ordered list of preferred countries and media types. A distance penalty will be applied if the country or media type from the match metadata doesn't match. The specified values are preferred in descending order (i.e., the first item will be most preferred). Each item may be a regular expression, and will be matched case insensitively. The number of media will be stripped when matching preferred media (e.g. "2x" in "2xCD"). You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. Here's an example: :: match: preferred: countries: ['US', 'GB|UK'] media: ['CD', 'Digital Media|File'] original_year: yes By default, none of these options are enabled. .. _ignored: ignored ~~~~~~~ You can completely avoid matches that have certain penalties applied by adding the penalty name to the ``ignored`` setting: :: match: ignored: missing_tracks unmatched_tracks The available penalties are the same as those for the :ref:`max_rec` setting. For example, setting ``ignored: missing_tracks`` will skip any album matches where your audio files are missing some of the tracks. The importer will not attempt to display these matches. It does not ignore the fact that the album is missing tracks, which would allow these matches to apply more easily. To do that, you'll want to adjust the penalty for missing tracks. .. _required: required ~~~~~~~~ You can avoid matches that lack certain required information. Add the tags you want to enforce to the ``required`` setting: :: match: required: year label catalognum country No tags are required by default. .. _ignored_media: ignored_media ~~~~~~~~~~~~~ A list of media (i.e., formats) in metadata databases to ignore when matching music. You can use this to ignore all media that usually contain video instead of audio, for example: :: match: ignored_media: ['Data CD', 'DVD', 'DVD-Video', 'Blu-ray', 'HD-DVD', 'VCD', 'SVCD', 'UMD', 'VHS'] No formats are ignored by default. .. _ignore_data_tracks: ignore_data_tracks ~~~~~~~~~~~~~~~~~~ By default, audio files contained in data tracks within a release are included in the album's tracklist. If you want them to be included, set it ``no``. Default: ``yes``. .. _ignore_video_tracks: ignore_video_tracks ~~~~~~~~~~~~~~~~~~~ By default, video tracks within a release will be ignored. If you want them to be included (for example if you would like to track the audio-only versions of the video tracks), set it to ``no``. Default: ``yes``. .. _path-format-config: Path Format Configuration ------------------------- You can also configure the directory hierarchy beets uses to store music. These settings appear under the ``paths:`` key. Each string is a template string that can refer to metadata fields like ``$artist`` or ``$title``. The filename extension is added automatically. At the moment, you can specify three special paths: ``default`` for most releases, ``comp`` for "various artist" releases with no dominant artist, and ``singleton`` for non-album tracks. The defaults look like this: :: paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title Note the use of ``$albumartist`` instead of ``$artist``; this ensures that albums will be well-organized. For more about these format strings, see :doc:`pathformat`. The ``aunique{}`` function ensures that identically-named albums are placed in different directories; see :ref:`aunique` for details. In addition to ``default``, ``comp``, and ``singleton``, you can condition path queries based on beets queries (see :doc:`/reference/query`). This means that a config file like this: :: paths: albumtype:soundtrack: Soundtracks/$album/$track $title will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches multiple queries, beets will use the path format for the *first* matching query. Note that the special ``singleton`` and ``comp`` path format conditions are, in fact, just shorthand for the explicit queries ``singleton:true`` and ``comp:true``. In contrast, ``default`` is special and has no query equivalent: the ``default`` format is only used if no queries match. Configuration Location ---------------------- The beets configuration file is usually located in a standard location that depends on your OS, but there are a couple of ways you can tell beets where to look. Environment Variable ~~~~~~~~~~~~~~~~~~~~ First, you can set the ``BEETSDIR`` environment variable to a directory containing a ``config.yaml`` file. This replaces your configuration in the default location. This also affects where auxiliary files, like the library database, are stored by default (that's where relative paths are resolved to). This environment variable is useful if you need to manage multiple beets libraries with separate configurations. Command-Line Option ~~~~~~~~~~~~~~~~~~~ Alternatively, you can use the ``--config`` command-line option to indicate a YAML file containing options that will then be merged with your existing options (from ``BEETSDIR`` or the default locations). This is useful if you want to keep your configuration mostly the same but modify a few options as a batch. For example, you might have different strategies for importing files, each with a different set of importer options. Default Location ~~~~~~~~~~~~~~~~ In the absence of a ``BEETSDIR`` variable, beets searches a few places for your configuration, depending on the platform: - On Unix platforms, including OS X:``~/.config/beets`` and then ``$XDG_CONFIG_DIR/beets``, if the environment variable is set. - On OS X, we also search ``~/Library/Application Support/beets`` before the Unixy locations. - On Windows: ``~\AppData\Roaming\beets``, and then ``%APPDATA%\beets``, if the environment variable is set. Beets uses the first directory in your platform's list that contains ``config.yaml``. If no config file exists, the last path in the list is used. .. _config-example: Example ------- Here's an example file: :: directory: /var/mp3 import: copy: yes write: yes log: beetslog.txt art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins ui: color: yes paths: default: %first{$genres}/$albumartist/$album/$track $title singleton: Singletons/$artist - $title comp: %first{$genres}/$album/$track $title albumtype:soundtrack: Soundtracks/$album/$track $title .. only:: man See Also -------- ``https://beets.readthedocs.org/`` :manpage:`beet(1)` ================================================ FILE: docs/reference/index.rst ================================================ Reference ========= This section contains reference materials for various parts of beets. To get started with beets as a new user, though, you may want to read the :doc:`/guides/main` guide first. .. toctree:: :maxdepth: 2 cli config pathformat query ================================================ FILE: docs/reference/pathformat.rst ================================================ Path Formats ============ The ``paths:`` section of the config file (see :doc:`config`) lets you specify the directory and file naming scheme for your music library. Templates substitute symbols like ``$title`` (any field value prefixed by ``$``) with the appropriate value from the track's metadata. Beets adds the filename extension automatically. For example, consider this path format string: ``$albumartist/$album/$track $title`` Here are some paths this format will generate: - ``Yeah Yeah Yeahs/It's Blitz!/01 Zero.mp3`` - ``Spank Rock/YoYoYoYoYo/11 Competition.mp3`` - ``The Magnetic Fields/Realism/01 You Must Be Out of Your Mind.mp3`` Because ``$`` is used to delineate a field reference, you can use ``$$`` to emit a dollars sign. As with `Python template strings`_, ``${title}`` is equivalent to ``$title``; you can use this if you need to separate a field name from the text that follows it. .. _python template strings: https://docs.python.org/3/library/string.html#template-strings-strings A Note About Artists -------------------- Note that in path formats, you almost certainly want to use ``$albumartist`` and not ``$artist``. The latter refers to the "track artist" when it is present, which means that albums that have tracks from different artists on them (like `Stop Making Sense`_, for example) will be placed into different folders! Continuing with the Stop Making Sense example, you'll end up with most of the tracks in a "Talking Heads" directory and one in a "Tom Tom Club" directory. You probably don't want that! So use ``$albumartist``. .. _stop making sense: https://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36 As a convenience, however, beets allows ``$albumartist`` to fall back to the value for ``$artist`` and vice-versa if one tag is present but the other is not. .. _template-functions: Template Functions ------------------ Beets path formats also support *function calls*, which can be used to transform text and perform logical manipulations. The syntax for function calls is like this: ``%func{arg,arg}``. For example, the ``upper`` function makes its argument upper-case, so ``%upper{beets rocks}`` will be replaced with ``BEETS ROCKS``. You can, of course, nest function calls and place variable references in function arguments, so ``%upper{$artist}`` becomes the upper-case version of the track's artists. These functions are built in to beets: - ``%lower{text}``: Convert ``text`` to lowercase. - ``%upper{text}``: Convert ``text`` to UPPERCASE. - ``%capitalize{text}``: Make the first letter of ``text`` UPPERCASE and the rest lowercase. - ``%title{text}``: Convert ``text`` to Title Case. - ``%left{text,n}``: Return the first ``n`` characters of ``text``. - ``%right{text,n}``: Return the last ``n`` characters of ``text``. - ``%if{condition,text}`` or ``%if{condition,truetext,falsetext}``: If ``condition`` is not empty, and not one of the values ``0`` or ``false`` (case insensitive), then returns the second argument. Otherwise, returns the third argument if specified (or nothing if ``falsetext`` is left off). - ``%asciify{text}``: Convert non-ASCII characters to their ASCII equivalents. For example, "café" becomes "cafe". Uses the mapping provided by the `unidecode module`_. See the :ref:`asciify-paths` configuration option. - ``%aunique{identifiers,disambiguators,brackets}``: Provides a unique string to disambiguate similar albums in the database. See :ref:`aunique`, below. - ``%sunique{identifiers,disambiguators,brackets}``: Similarly, a unique string to disambiguate similar singletons in the database. See :ref:`sunique`, below. - ``%time{date_time,format}``: Return the date and time in any format accepted by strftime_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. - ``%first{text,count,skip,sep,join}``: Extract a subset of items from a delimited string. Splits ``text`` by ``sep``, skips the first ``skip`` items, then returns the next ``count`` items joined by ``join``. This is especially useful for multi-valued fields like ``artists`` or ``genres`` where you may only want the first artist or a limited number of genres in a path. Defaults: .. Comically, you need to follow |semicolon_space| with some punctuation to make sure it gets rendered correctly as '; ' in the docs. - **count**: 1, - **skip**: 0, - **sep**: |semicolon_space|, - **join**: |semicolon_space|. Examples: :: %first{$genres} → returns the first genre %first{$genres,2} → returns the first two genres, joined by "; " %first{$genres,2,1} → skips the first genre, returns the next two %first{$genres,2,0, , -> } → splits by space, joins with " -> " - ``%ifdef{field}``, ``%ifdef{field,truetext}`` or ``%ifdef{field,truetext,falsetext}``: Checks if an flexible attribute ``field`` is defined. If it exists, then return ``truetext`` or ``field`` (default). Otherwise, returns ``falsetext``. The ``field`` should be entered without ``$``. Note that this doesn't work with built-in :ref:`itemfields`, as they are always defined. .. _strftime: https://docs.python.org/3/library/time.html#time.strftime .. _unidecode module: https://pypi.org/project/Unidecode Plugins can extend beets with more template functions (see :ref:`templ_plugins`). .. _aunique: Album Disambiguation -------------------- Occasionally, bands release two albums with the same name (c.f. Crystal Castles, Weezer, and any situation where a single has the same name as an album or EP). Beets ships with special support, in the form of the ``%aunique{}`` template function, to avoid placing two identically-named albums in the same directory on disk. The ``aunique`` function detects situations where two albums have some identical fields and emits text from additional fields to disambiguate the albums. For example, if you have both Crystal Castles albums in your library, ``%aunique{}`` will expand to "[2008]" for one album and "[2010]" for the other. The function detects that you have two albums with the same artist and title but that they have different release years. For full flexibility, the ``%aunique`` function takes three arguments. The first two are whitespace-separated lists of album field names: a set of *identifiers* and a set of *disambiguators*. The third argument is a pair of characters used to surround the disambiguator. Any group of albums with identical values for all the identifiers will be considered "duplicates". Then, the function tries each disambiguator field, looking for one that distinguishes each of the duplicate albums from each other. The first such field is used as the result for ``%aunique``. If no field suffices, an arbitrary number is used to distinguish the two albums. The default identifiers are ``albumartist album`` and the default disambiguators are ``albumtype year label catalognum albumdisambig releasegroupdisambig``. So you can get reasonable disambiguation behavior if you just use ``%aunique{}`` with no parameters in your path forms (as in the default path formats), but you can customize the disambiguation if, for example, you include the year by default in path formats. The default characters used as brackets are ``[]``. To change this, provide a third argument to the ``%aunique`` function consisting of two characters: the left and right brackets. Or, to turn off bracketing entirely, leave argument blank. One caveat: When you import an album that is named identically to one already in your library, the *first* album—the one already in your library— will not consider itself a duplicate at import time. This means that ``%aunique{}`` will expand to nothing for this album and no disambiguation string will be used at its import time. Only the second album will receive a disambiguation string. If you want to add the disambiguation string to both albums, just run ``beet move`` (possibly restricted by a query) to update the paths for the albums. .. _sunique: Singleton Disambiguation ------------------------ It is also possible to have singleton tracks with the same name and the same artist. Beets provides the ``%sunique{}`` template to avoid giving these tracks the same file path. It has the same arguments as the :ref:`%aunique <aunique>` template, but the default values are different. The default identifiers are ``artist title`` and the default disambiguators are ``year trackdisambig``. Syntax Details -------------- The characters ``$``, ``%``, ``{``, ``}``, and ``,`` are "special" in the path template syntax. This means that, for example, if you want a ``%`` character to appear in your paths, you'll need to be careful that you don't accidentally write a function call. To escape any of these characters (except ``{``, and ``,`` outside a function argument), prefix it with a ``$``. For example, ``$$`` becomes ``$``; ``$%`` becomes ``%``, etc. The only exceptions are: - ``${``, which is ambiguous with the variable reference syntax (like ``${title}``). To insert a ``{`` alone, it's always sufficient to just type ``{``. You do, however need to use ``$`` to escape a closing brace ``$}``. - commas are used as argument separators in function calls. Inside of a function's argument, use ``$,`` to get a literal ``,`` character. Outside of any function argument, escaping is not necessary: ``,`` by itself will produce ``,`` in the output. If a value or function is undefined, the syntax is simply left unreplaced. For example, if you write ``$foo`` in a path template, this will yield ``$foo`` in the resulting paths because "foo" is not a valid field name. The same is true of syntax errors like unclosed ``{}`` pairs; if you ever see template syntax constructs leaking into your paths, check your template for errors. If an error occurs in the Python code that implements a function, the function call will be expanded to a string that describes the exception so you can debug your template. For example, the second parameter to ``%left`` must be an integer; if you write ``%left{foo,bar}``, this will be expanded to something like ``<ValueError: invalid literal for int()>``. .. _itemfields: Available Values ---------------- Here's a list of the different values available to path formats. The current list can be found definitively by running the command ``beet fields``. Note that plugins can add new (or replace existing) template values (see :ref:`templ_plugins`). Ordinary metadata: - title - artist - artist_sort: The "sort name" of the track artist (e.g., "Beatles, The" or "White, Jack"). - artist_credit: The track-specific `artist credit`_ name, which may be a variation of the artist's "canonical" name. - album - albumartist: The artist for the entire album, which may be different from the artists for the individual tracks. - albumartist_sort - albumartist_credit - genre - composer - grouping - year, month, day: The release date of the specific release. - original_year, original_month, original_day: The release date of the original version of the album. - track - tracktotal - disc - disctotal - lyrics - comments - bpm - comp: Compilation flag. - albumtype: The MusicBrainz album type; the MusicBrainz wiki has a `list of type names`_. - label - asin - catalognum - script - language - country - albumstatus - media - albumdisambig - disctitle - encoder .. _artist credit: https://wiki.musicbrainz.org/Artist_Credit .. _list of type names: https://musicbrainz.org/doc/Release_Group/Type Audio information: - length (in seconds) - bitrate (in kilobits per second, with units: e.g., "192kbps") - bitrate_mode (e.g., "CBR", "VBR" or "ABR", only available for the MP3 format) - encoder_info (e.g., "LAME 3.97.0", only available for some formats) - encoder_settings (e.g., "-V2", only available for the MP3 format) - format (e.g., "MP3" or "FLAC") - channels - bitdepth (only available for some formats) - samplerate (in kilohertz, with units: e.g., "48kHz") MusicBrainz and fingerprint information: - mb_trackid - mb_releasetrackid - mb_albumid - mb_artistid - mb_albumartistid - mb_releasegroupid - acoustid_fingerprint - acoustid_id Library metadata: - mtime: The modification time of the audio file. - added: The date and time that the music was added to your library. - path: The item's filename. .. _templ_plugins: Template functions and values provided by plugins ------------------------------------------------- Beets plugins can provide additional fields and functions to templates. See the :doc:`/plugins/index` page for a full list of plugins. Some plugin-provided constructs include: - ``$missing`` by :doc:`/plugins/missing`: The number of missing tracks per album. - ``$album_artist_no_feat`` by :doc:`/plugins/ftintitle`: The album artist without any featured artists - ``%bucket{text}`` by :doc:`/plugins/bucket`: Substitute a string by the range it belongs to. - ``%the{text}`` by :doc:`/plugins/the`: Moves English articles to ends of strings. The :doc:`/plugins/inline` lets you define template fields in your beets configuration file using Python snippets. And for more advanced processing, you can go all-in and write a dedicated plugin to register your own fields and functions (see :ref:`basic-plugin-setup`). ================================================ FILE: docs/reference/query.rst ================================================ .. _queries: Queries ======= Many of beets' :doc:`commands <cli>` are built around **query strings:** searches that select tracks and albums from your library. This page explains the query string syntax, which is meant to vaguely resemble the syntax used by Web search engines. .. _keywordquery: Keyword ------- This command: :: $ beet list love will show all tracks matching the query string ``love``. By default any unadorned word like this matches in a track's title, artist, album name, album artist, genre and comments. See below on how to search other fields. For example, this is what I might see when I run the command above: :: Against Me! - Reinventing Axl Rose - I Still Love You Julie Air - Love 2 - Do the Joy Bag Raiders - Turbo Love - Shooting Stars Bat for Lashes - Two Suns - Good Love ... .. _combiningqueries: Combining Keywords ------------------ Multiple keywords are implicitly joined with a Boolean "and." That is, if a query has two keywords, it only matches tracks that contain *both* keywords. For example, this command: :: $ beet ls magnetic tomorrow matches songs from the album "The House of Tomorrow" by The Magnetic Fields in my library. It *doesn't* match other songs by the Magnetic Fields, nor does it match "Tomorrowland" by Walter Meego---those songs only have *one* of the two keywords I specified. Keywords can also be joined with a Boolean "or" using a comma. For example, the command: :: $ beet ls magnetic tomorrow , beatles yesterday will match both "The House of Tomorrow" by the Magnetic Fields, as well as "Yesterday" by The Beatles. Note that the comma has to be followed by a space (e.g., ``foo,bar`` will be treated as a single keyword, *not* as an OR-query). .. _fieldsquery: Specific Fields --------------- Sometimes, a broad keyword match isn't enough. Beets supports a syntax that lets you query a specific field---only the artist, only the track title, and so on. Just say ``field:value``, where ``field`` is the name of the thing you're trying to match (such as ``artist``, ``album``, or ``title``) and ``value`` is the keyword you're searching for. For example, while this query: :: $ beet list dream matches a lot of songs in my library, this more-specific query: :: $ beet list artist:dream only matches songs by the artist The-Dream. One query I especially appreciate is one that matches albums by year: :: $ beet list -a year:2012 Recall that ``-a`` makes the ``list`` command show albums instead of individual tracks, so this command shows me all the releases I have from this year. For multi-valued tags (such as ``artists`` or ``albumartists``), a regular expression search must be used to search for a single value within the multi-valued tag. Note that you can filter albums by querying tracks fields and vice versa: :: $ beet list -a title:love and vice versa: :: $ beet list art_path::love Phrases ------- You can query for strings with spaces in them by quoting or escaping them using your shell's argument syntax. For example, this command: :: $ beet list the rebel shows several tracks in my library, but these (equivalent) commands: :: $ beet list "the rebel" $ beet list the\ rebel only match the track "The Rebel" by Buck 65. Note that the quotes and backslashes are not part of beets' syntax; I'm just using the escaping functionality of my shell (bash or zsh, for instance) to pass ``the rebel`` as a single argument instead of two. .. _exact-match: Exact Matches ------------- While ordinary queries perform *substring* matches, beets can also match whole strings by adding either ``=`` (case-sensitive) or ``=~`` (ignore case) after the field name's colon and before the expression: :: $ beet list artist:air $ beet list artist:=~air $ beet list artist:=AIR The first query is a simple substring one that returns tracks by Air, AIR, and Air Supply. The second query returns tracks by Air and AIR, since both are a case-insensitive match for the entire expression, but does not return anything by Air Supply. The third query, which requires a case-sensitive exact match, returns tracks by AIR only. Exact matches may be performed on phrases as well: :: $ beet list artist:=~"dave matthews" $ beet list artist:="Dave Matthews" Both of these queries return tracks by Dave Matthews, but not by Dave Matthews Band. To search for exact matches across *all* fields, just prefix the expression with a single ``=`` or ``=~``: :: $ beet list =~crash $ beet list ="American Football" .. _regex: Regular Expressions ------------------- In addition to simple substring and exact matches, beets also supports regular expression matching for more advanced queries. To run a regex query, use an additional ``:`` between the field name and the expression: :: $ beet list "artist::Ann(a|ie)" That query finds songs by Anna Calvi and Annie but not Annuals. Similarly, this query prints the path to any file in my library that's missing a track title: :: $ beet list -p title::^$ To search *all* fields using a regular expression, just prefix the expression with a single ``:``, like so: :: $ beet list ":Ho[pm]eless" Regular expressions are case-sensitive and build on `Python's built-in implementation <https://docs.python.org/3/library/re.html>`__. See Python's documentation for specifics on regex syntax. Most command-line shells will try to interpret common characters in regular expressions, such as ``()[]|``. To type those characters, you'll need to escape them (e.g., with backslashes or quotation marks, depending on your shell). .. _numericquery: Numeric Range Queries --------------------- For numeric fields, such as year, bitrate, and track, you can query using one-or two-sided intervals. That is, you can find music that falls within a *range* of values. To use ranges, write a query that has two dots (``..``) at the beginning, middle, or end of a string of numbers. Dots in the beginning let you specify a maximum (e.g., ``..7``); dots at the end mean a minimum (``4..``); dots in the middle mean a range (``4..7``). For example, this command finds all your albums that were released in the '90s: :: $ beet list -a year:1990..1999 and this command finds MP3 files with bitrates of 128k or lower: :: $ beet list format:MP3 bitrate:..128000 The ``length`` field also lets you use a "M:SS" format. For example, this query finds tracks that are less than four and a half minutes in length: :: $ beet list length:..4:30 .. _datequery: Date and Date Range Queries --------------------------- Date-valued fields, such as *added* and *mtime*, have a special query syntax that lets you specify years, months, and days as well as ranges between dates. Dates are written separated by hyphens, like ``year-month-day``, but the month and day are optional. If you leave out the day, for example, you will get matches for the whole month. Date *intervals*, like the numeric intervals described above, are separated by two dots (``..``). You can specify a start, an end, or both. Here is an example that finds all the albums added in 2008: :: $ beet ls -a 'added:2008' Find all items added in the years 2008, 2009 and 2010: :: $ beet ls 'added:2008..2010' Find all items added before the year 2010: :: $ beet ls 'added:..2009' Find all items added on or after 2008-12-01 but before 2009-10-12: :: $ beet ls 'added:2008-12..2009-10-11' Find all items with a file modification time between 2008-12-01 and 2008-12-03: :: $ beet ls 'mtime:2008-12-01..2008-12-02' You can also add an optional time value to date queries, specifying hours, minutes, and seconds. Times are separated from dates by a space, an uppercase 'T' or a lowercase 't', for example: ``2008-12-01T23:59:59``. If you specify a time, then the date must contain a year, month, and day. The minutes and seconds are optional. Here is an example that finds all items added on 2008-12-01 at or after 22:00 but before 23:00: :: $ beet ls 'added:2008-12-01T22' To find all items added on or after 2008-12-01 at 22:45: :: $ beet ls 'added:2008-12-01T22:45..' To find all items added on 2008-12-01, at or after 22:45:20 but before 22:45:41: :: $ beet ls 'added:2008-12-01T22:45:20..2008-12-01T22:45:40' Here are example of the three ways to separate dates from times. All of these queries do the same thing: :: $ beet ls 'added:2008-12-01T22:45:20' $ beet ls 'added:2008-12-01t22:45:20' $ beet ls 'added:2008-12-01 22:45:20' You can also use *relative* dates. For example, ``-3w`` means three weeks ago, and ``+4d`` means four days in the future. A relative date has three parts: - Either ``+`` or ``-``, to indicate the past or the future. The sign is optional; if you leave this off, it defaults to the future. - A number. - A letter indicating the unit: ``d``, ``w``, ``m`` or ``y``, meaning days, weeks, months or years. (A "month" is always 30 days and a "year" is always 365 days.) Here's an example that finds all the albums added since last week: :: $ beet ls -a 'added:-1w..' And here's an example that lists items added in a two-week period starting four weeks ago: :: $ beet ls 'added:-6w..-4w' .. _not_query: Query Term Negation ------------------- Query terms can also be negated, acting like a Boolean "not," by prefixing them with ``-`` or ``^``. This has the effect of returning all the items that do **not** match the query term. For example, this command: :: $ beet list ^love matches all the songs in the library that do not have "love" in any of their fields. Negation can be combined with the rest of the query mechanisms, so you can negate specific fields, regular expressions, etc. For example, this command: :: $ beet list -a artist:dylan ^year:1980..1989 "^album::the(y)?" matches all the albums with an artist containing "dylan", but excluding those released in the eighties and those that have "the" or "they" on the title. The syntax supports both ``^`` and ``-`` as synonyms because the latter indicates flags on the command line. To use a minus sign in a command-line query, use a double dash ``--`` to separate the options from the query: :: $ beet list -a -- artist:dylan -year:1980..1990 "-album::the(y)?" .. _pathquery: Path Queries ------------ Sometimes it's useful to find all the items in your library that are (recursively) inside a certain directory. Use the ``path:`` field to do this: :: $ beet list path:/my/music/directory In fact, beets automatically recognizes any query term containing a path separator (``/`` on POSIX systems) as a path query if that path exists, so this command is equivalent as long as ``/my/music/directory`` exist: :: $ beet list /my/music/directory Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. Path queries are case sensitive if the queried path is on a case-sensitive filesystem. .. _query-sort: Sort Order ---------- Queries can specify a sort order. Use the name of the ``field`` you want to sort on, followed by a ``+`` or ``-`` sign to indicate ascending or descending sort. For example, this command: :: $ beet list -a year+ will list all albums in chronological order. You can also specify several sort orders, which will be used in the same order as they appear in your query: :: $ beet list -a genre+ year+ This command will sort all albums by genre and, in each genre, in chronological order. The ``artist`` and ``albumartist`` keys are special: they attempt to use their corresponding ``artist_sort`` and ``albumartist_sort`` fields for sorting transparently (but fall back to the ordinary fields when those are empty). Lexicographic sorts are case insensitive by default, resulting in the following sort order: ``Bar foo Qux``. This behavior can be changed with the :ref:`sort_case_insensitive` configuration option. Case sensitive sort will result in lower-case values being placed after upper-case values, e.g., ``Bar Qux foo``. Note that when sorting by fields that are not present on all items (such as flexible fields, or those defined by plugins) in *ascending* order, the items that lack that particular field will be listed at the *beginning* of the list. You can set the default sorting behavior with the :ref:`sort_item` and :ref:`sort_album` configuration options. ================================================ FILE: docs/team.rst ================================================ Team ==== This is an introduction of beets' core-team members, collaborators and frequent contributors. Refer to this list to find out who to ask about your collaboration idea, discuss a usage-question, request a review of your open PR. Beets is a huge project and not everyone involved, knows everything. We hope this helps to point you in the right direction in the first place and should give you an idea of what you can expect from these *knowledge owners*. @arsaboo -------- - The master of the Spotify plugin - Testing out new contributions - beets as a music discovery tool @bal-e ------ - Documentation - The Fish plugin - Type annotations @govynnus --------- - The AURA plugin - The AURA specification - The web plugin - The plugin ecosystem - The library database API and its documentation @jackwilsdon ------------ - Broad knowledge around beets' configuration and plugins - Assists in discussion forums frequently - Knows internals of beets and puts new contributors into the right direction @joj0 ----- - The Discogs plugin - Good documentation throughout the project - The smartplaylist plugin - The lastgenre plugin - Support for M3U and other playlist formats - beets as a DJ companion tool (BPM, audio features, key) @RollingStar ------------ - Data visualization - ListenBrainz / Last.fm - Smart playlists - Library reports - MusicBrainz fields and searching - Project organization and roadmap @sampsyo -------- - The founder - Knows almost everything ;-) @serene-arc ----------- - Good documentation throughout the project - Experienced Python developer - Experienced in test-driven-development - Code quality - Typing @snejus ------- - Grug-minded approach: simple, obvious solutions over clever complexity - MusicBrainz/autotagger internals and source-plugin behavior - Query/path handling and SQL-backed lookup behavior - Typing and tooling modernization (mypy, Ruff, annotations) - Test architecture, CI reliability, and coverage improvements - Release and packaging workflows (Poetry/pyproject, dependencies, changelog) - Cross-plugin refactors (especially metadata and lyrics-related internals) @wisp3rwind ----------- - Mr. Tidy - Keeping the code in shape - Focus on improving core things rather than implementing new features @ybnd ----- - The replaygain plugin - Improving the general parallelism of plugins - Experienced with web scrapers - Experienced with Flask and JavaScript integration - The web plugin ================================================ FILE: extra/_beet ================================================ #compdef beet # zsh completion for beets music library manager and MusicBrainz tagger: https://beets.io/ # Default values for BEETS_LIBRARY & BEETS_CONFIG needed for the cache checking function. # They will be updated under the assumption that the config file is in the same directory as the library. local BEETS_LIBRARY=~/.config/beets/library.db local BEETS_CONFIG=~/.config/beets/config.yaml # Use separate caches for file locations, command completions, and query completions. # This allows the use of different rules for when to update each one. zstyle ":completion:${curcontext%:*}:*" cache-policy _beet_check_cache zstyle ":completion:${curcontext%:*}:*" use-cache true _beet_check_cache () { local cachefile="$(basename ${1})" if [[ ! -a "${1}" ]] || [[ "${1}" -ot =beet ]]; then # always update the cache if it doesn't exist, or if the beet executable changes return 0 fi case cachefile; in (beetslibrary) if [[ ! -a "${~BEETS_LIBRARY}" ]] || [[ "${1}" -ot "${~BEETS_CONFIG}" ]]; then return 0 fi ;; (beetscmds) _retrieve_cache beetslibrary if [[ "${1}" -ot "${~BEETS_CONFIG}" ]]; then return 0 fi ;; esac return 1 } # useful: argument to _regex_arguments for matching any word local matchany=/$'[^\0]##\0'/ # arguments to _regex_arguments for completing files and directories local -a files dirs files=("$matchany" ':file:file:_files') dirs=("$matchany" ':dir:directory:_dirs') # Retrieve or update caches if ! _retrieve_cache beetslibrary || _cache_invalid beetslibrary; then local BEETS_LIBRARY="${$(beet config|grep library|cut -f 2 -d ' '):-${BEETS_LIBRARY}}" local BEETS_CONFIG="${$(beet config -p):-${BEETS_CONFIG}}" _store_cache beetslibrary BEETS_LIBRARY BEETS_CONFIG fi if ! _retrieve_cache beetscmds || _cache_invalid beetscmds; then local -a subcommands fields beets_regex_words_subcmds beets_regex_words_help query modify local subcmd cmddesc matchquery matchmodify field fieldargs queryelem modifyelem # Useful function for joining grouped lines of output into single lines (taken from _completion_helpers) _join_lines() { awk -v SEP="$1" -v ARG2="$2" -v START="$3" -v END2="$4" 'BEGIN {if(START==""){f=1}{f=0}; if(ARG2 ~ "^[0-9]+"){LINE1 = "^[[:space:]]{0,"ARG2"}[^[:space:]]"}else{LINE1 = ARG2}} ($0 ~ END2 && f>0 && END2!="") {exit} ($0 ~ START && f<1) {f=1; if(length(START)!=0){next}} ($0 ~ LINE1 && f>0) {if(f<2){f=2; printf("%s",$0)}else{printf("\n%s",$0)}; next} (f>1) {gsub(/^[[:space:]]+|[[:space:]]+$/,"",$0); printf("%s%s",SEP, $0); next} END {print ""}' } # Variables used for completing subcommands and queries subcommands=(${${(f)"$(beet help | _join_lines ' ' 3 'Commands:')"}[@]}) fields=($(beet fields | grep -G '^ ' | sort -u | colrm 1 2)) for field in "${fields[@]}" do fieldargs="$fieldargs '$field:::{_beet_field_values $field}'" done queryelem="_values -S : 'query field (add an extra : to match by regexp)' '::' $fieldargs" modifyelem="_values -S = 'modify field (replace = with ! to remove field)' $(echo "'${^fields[@]}:: '")" # regexps for matching query and modify terms on the command line matchquery=/"(${(j/|/)fields[@]})"$':[^\0]##\0'/ matchmodify=/"(${(j/|/)fields[@]})"$'(=[^\0]##|!)\0'/ # create completion function for queries _regex_arguments _beet_query "$matchany" \# \( "$matchquery" ":query:query string:$queryelem" \) \# local "beets_query"="$(which _beet_query)" # arguments for _regex_arguments for completing lists of queries and modifications beets_query_args=( \( "$matchquery" ":query:query string:{_beet_query}" \) \# ) beets_modify_args=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# ) # now build arguments for _beet and _beet_help completion functions beets_regex_words_subcmds=('(') for i in ${subcommands[@]}; do subcmd="${i[(w)1]}" # remove first word and parenthesised alias, replace : with -, [ with (, ] with ), and remove single quotes cmddesc="${${${${${i[(w)2,-1]##\(*\) #}//:/-}//\[/(}//\]/)}//\'/}" # update arguments needed for creating _beet beets_regex_words_subcmds+=(/"${subcmd}"$'\0'/ ":subcmds:subcommands:((${subcmd}:${cmddesc// /\ }))") beets_regex_words_subcmds+=(\( "${matchany}" ":option:option:{_beet_subcmd ${subcmd}}" \) \# \|) # update arguments needed for creating _beet_help beets_regex_words_help+=("${subcmd}:${cmddesc}") done beets_regex_words_subcmds[-1]=')' _store_cache beetscmds beets_regex_words_subcmds beets_regex_words_help beets_query_args beets_modify_args beets_query else # Evaluate the variable containing the query completer function eval "${beets_query}" fi # Function for getting unique values for field from database (you may need to change the path to the database). _beet_field_values() { local -a output fieldvals local sqlcmd="select distinct $1 from items;" _retrieve_cache beetslibrary case ${1} in lyrics) fieldvals= ;; *) if [[ "$(sqlite3 ${~BEETS_LIBRARY} ${sqlcmd} 2>&1)" =~ "no such column" ]]; then sqlcmd="select distinct value from item_attributes where key=='$1' and value!='';" fi output="$(sqlite3 -list -noheader ${~BEETS_LIBRARY} ${sqlcmd} 2>/dev/null)" fieldvals=("${(f)output[@]}") ;; esac compadd -P \" -S \" -M 'm:{[:lower:][:upper:]}={[:upper:][:lower:]}' -Q -a fieldvals } # This function takes a beet subcommand as its first argument, and then uses _regex_words to set ${reply[@]} # to an array containing arguments for the _regex_arguments function. _beet_subcmd_options() { local shortopt optarg optdesc local matchany=/$'[^\0]##\0'/ local -a regex_words regex_words=() for i in ${${(f)"$(beet help $1 | awk '/^ +-/{if(x)print x;x=$0;next}/^ *$/{if(x) exit}{if(x) x=x$0}END{print x}')"}[@]} do opt="${i[(w)1]/,/}" optarg="${${${i## #[-a-zA-Z]# }##[- ]##*}%%[, ]*}" optdesc="${${${${${i[(w)2,-1]/[A-Z, ]#--[-a-z]##[=A-Z]# #/}//:/-}//\[/(}//\]/)}//\'/}" case $optarg; in ("") if [[ "$1" == "import" && "$opt" == "-L" ]]; then regex_words+=("$opt:$optdesc:\${beets_query_args[@]}") else regex_words+=("$opt:$optdesc") fi ;; (LOG) local -a files files=("$matchany" ':file:file:_files') regex_words+=("$opt:$optdesc:\$files") ;; (CONFIG) local -a configfile configfile=("$matchany" ':file:config file:{_files -g *.yaml}') regex_words+=("$opt:$optdesc:\$configfile") ;; (LIB|LIBRARY) local -a libfile libfile=("$matchany" ':file:database file:{_files -g *.db}') regex_words+=("$opt:$optdesc:\$libfile") ;; (DIR|DIRECTORY|DEST) local -a dirs dirs=("$matchany" ':dir:directory:_dirs') regex_words+=("$opt:$optdesc:\$dirs") ;; (SOURCE) if [[ "${1}" -eq lastgenre ]]; then local -a lastgenresource lastgenresource=(/$'(artist|album|track)\0'/ ':source:genre source:(artist album track)') regex_words+=("$opt:$optdesc:\$lastgenresource") else regex_words+=("$opt:$optdesc:\$matchany") fi ;; (*) regex_words+=("$opt:$optdesc:\$matchany") ;; esac done _regex_words options "$1 options" "${regex_words[@]}" } ## Function for completing subcommands. It calls another completion function which is first created if it doesn't already exist. _beet_subcmd() { local -a options local subcmd="${1}" if [[ ! $(type _beet_${subcmd} | grep function) =~ function ]]; then if ! _retrieve_cache "beets${subcmd}" || _cache_invalid "beets${subcmd}"; then local matchany=/$'[^\0]##\0'/ local -a files files=("$matchany" ':file:file:_files') # get arguments for completing subcommand options _beet_subcmd_options "$subcmd" options=("${reply[@]}" \#) _retrieve_cache beetscmds case ${subcmd}; in (import) _regex_arguments _beet_import "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}" "${files[@]}" \# ;; (modify) _regex_arguments _beet_modify "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}" \ "${beets_query_args[@]}" "${beets_modify_args[@]}" ;; (fields|migrate|version|config) _regex_arguments _beet_${subcmd} "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}" ;; (help) _regex_words subcmds "subcommands" "${beets_regex_words_help[@]}" _regex_arguments _beet_help "${matchany}" /$'help\0'/ "${options[@]}" "${reply[@]}" ;; (*) # Other commands have options followed by a query _regex_arguments _beet_${subcmd} "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}" "${beets_query_args[@]}" ;; esac # Store completion function in a cache file local "beets_${subcmd}"="$(which _beet_${subcmd})" _store_cache "beets${subcmd}" "beets_${subcmd}" else # Evaluate the function which is stored in $beets_${subcmd} local var="beets_${subcmd}" eval "${(P)var}" fi fi _beet_${subcmd} } # Global options local -a globalopts _regex_words options "global options" '-c:path to configuration file:$files' '-v:print debugging information' \ '-l:library database file to use:$files' '-h:show this help message and exit' '-d:destination music directory:$dirs' globalopts=("${reply[@]}") # Create main completion function _regex_arguments _beet "$matchany" \( "${globalopts[@]}" \# \) "${beets_regex_words_subcmds[@]}" # Set tag-order so that options are completed separately from arguments zstyle ":completion:${curcontext}:" tag-order '! options' # Execute the completion function _beet "$@" # Local Variables: # mode:shell-script # End: ================================================ FILE: extra/ascii_logo.txt ================================================ [][][][] [][] [] [][][][] [][] [] [][][][] [][][][] [][][][] [][] [] [][] [] [][][][] [][][][] [][] [][] [][][][] [][][][] [][][][] [][][][] [][][][] [][] [][] [][] [][] [][] [][] [][][][] ================================================ FILE: extra/release.py ================================================ #!/usr/bin/env python3 """A utility script for automating the beets release process.""" from __future__ import annotations import re import subprocess from collections.abc import Callable from contextlib import redirect_stdout from datetime import datetime, timezone from functools import partial from io import StringIO from pathlib import Path from typing import NamedTuple, TypeAlias import click import tomli from packaging.version import Version, parse from sphinx.ext import intersphinx from docs.conf import rst_epilog BASE = Path(__file__).parent.parent.absolute() PYPROJECT = BASE / "pyproject.toml" CHANGELOG = BASE / "docs" / "changelog.rst" DOCS = "https://beets.readthedocs.io/en/stable" VERSION_HEADER = r"\d+\.\d+\.\d+ \([^)]+\)" RST_LATEST_CHANGES = re.compile( rf"{VERSION_HEADER}\n--+\s+(.+?)\n\n+{VERSION_HEADER}", re.DOTALL ) Replacement: TypeAlias = "tuple[str, str | Callable[[re.Match[str]], str]]" class Ref(NamedTuple): """A reference to documentation with ID, path, and optional title.""" id: str path: str | None title: str | None @classmethod def from_line(cls, line: str) -> Ref: """Create Ref from a Sphinx objects.inv line. Each line has the following structure: <id> [optional title : ] <relative-url-path> See the output of python -m sphinx.ext.intersphinx docs/_build/html/objects.inv """ if len(line_parts := line.split(" ", 1)) == 1: return cls(line, None, None) id, path_with_name = line_parts parts = [p.strip() for p in path_with_name.split(":", 1)] if len(parts) == 1: path, name = parts[0], None else: name, path = parts return cls(id, path, name) @property def url(self) -> str: """Full documentation URL.""" return f"{DOCS}/{self.path}" @property def name(self) -> str: """Display name (title if available, otherwise ID).""" return self.title or self.id def get_refs() -> dict[str, Ref]: """Parse Sphinx objects.inv and return dict of documentation references.""" objects_filepath = Path("docs/_build/html/objects.inv") if not objects_filepath.exists(): raise ValueError("Documentation does not exist. Run 'poe docs' first.") captured_output = StringIO() with redirect_stdout(captured_output): intersphinx.inspect_main([str(objects_filepath)]) lines = captured_output.getvalue().replace("\t", " ").splitlines() return { r.id: r for ln in lines if ln.startswith(" ") and (r := Ref.from_line(ln.strip())) } def create_rst_replacements() -> list[Replacement]: """Generate list of pattern replacements for RST changelog.""" refs = get_refs() def make_ref_link(ref_id: str, name: str | None = None) -> str: if ref_id.endswith("-cmd"): name = f"{ref_id.removesuffix('-cmd')} command" ref = refs[ref_id] return rf"`{name or ref.name} <{ref.url}>`_" commands = "|".join(r.split("-")[0] for r in refs if r.endswith("-cmd")) plugins = "|".join( r.split("/")[-1] for r in refs if r.startswith("plugins/") ) explicit_replacements = dict( line.removeprefix(".. ").split(" replace:: ") for line in filter(None, rst_epilog.splitlines()) ) return [ # Replace explicitly defined substitutions from rst_epilog # |BeetsPlugin| -> :class:`beets.plugins.BeetsPlugin` ( r"\|\w[^ ]*\|", lambda m: explicit_replacements.get(m[0], m[0]), ), # Replace Sphinx directives by documentation URLs, e.g., # :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html) # noqa: E501 # :ref:`list-cmd` -> [list command](DOCS/reference/cli.html#list-cmd) ( r":(?:ref|doc|class|conf):`+~?(?:([^`<]+)<)?/?([\w.:/_-]+)>?`+", lambda m: make_ref_link(m[2], m[1]), ), # Convert command references to documentation URLs # `beet move` or `move` command -> [move command](DOCS/reference/cli.html#move-cmd) # noqa: E501 ( rf"`+beet ({commands})`+|`+({commands})`+(?= command)", lambda m: make_ref_link(f"{m[1] or m[2]}-cmd"), ), # Convert plugin references to documentation URLs # `fetchart` plugin -> [fetchart](DOCS/plugins/fetchart.html) (rf"`+({plugins})`+", lambda m: make_ref_link(f"plugins/{m[1]}")), # Convert bug references to GitHub issue links (r":bug:`(\d+)`", r":bug: (#\1)"), # Convert user references to GitHub @mentions (r":user:`(\w+)`", r"\@\1"), ] order_bullet_points = partial( re.compile(r"(\n- .*?(?=\n(?! *(-|\d\.) )|$))", flags=re.DOTALL).sub, lambda m: "\n- ".join(sorted(m.group().split("\n- "), key=str.lower)), ) def update_docs_config(text: str, new: Version) -> str: new_major_minor = f"{new.major}.{new.minor}" text = re.sub(r"(?<=version = )[^\n]+", f'"{new_major_minor}"', text) return re.sub(r"(?<=release = )[^\n]+", f'"{new}"', text) def update_changelog(text: str, new: Version) -> str: new_header = f"{new} ({datetime.now(timezone.utc).date():%B %d, %Y})" return re.sub( # do not match if the new version is already present r"\nUnreleased\n--+\n", rf""" Unreleased ---------- .. New features ~~~~~~~~~~~~ .. Bug fixes ~~~~~~~~~ .. For plugin developers ~~~~~~~~~~~~~~~~~~~~~ .. Other changes ~~~~~~~~~~~~~ {new_header} {"-" * len(new_header)} """, text, ) UpdateVersionCallable = Callable[[str, Version], str] FILENAME_AND_UPDATE_TEXT: list[tuple[Path, UpdateVersionCallable]] = [ ( PYPROJECT, lambda text, new: re.sub(r"(?<=\nversion = )[^\n]+", f'"{new}"', text), ), ( BASE / "beets" / "__init__.py", lambda text, new: re.sub( r"(?<=__version__ = )[^\n]+", f'"{new}"', text ), ), (CHANGELOG, update_changelog), (BASE / "docs" / "conf.py", update_docs_config), ] def validate_new_version( ctx: click.Context, param: click.Argument, value: Version ) -> Version: """Validate the version is newer than the current one.""" with PYPROJECT.open("rb") as f: current = parse(tomli.load(f)["tool"]["poetry"]["version"]) if not value > current: msg = f"version must be newer than {current}" raise click.BadParameter(msg) return value def bump_version(new: Version) -> None: """Update the version number in specified files.""" for path, perform_update in FILENAME_AND_UPDATE_TEXT: with path.open("r+") as f: contents = f.read() f.seek(0) f.write(perform_update(contents, new)) f.truncate() def rst2md(text: str) -> str: """Use Pandoc to convert text from ReST to Markdown.""" return ( subprocess.check_output( ["pandoc", "--from=rst", "--to=gfm+hard_line_breaks"], input=text.encode(), ) .decode() .strip() ) def get_changelog_contents() -> str | None: if m := RST_LATEST_CHANGES.search(CHANGELOG.read_text()): return m.group(1) return None def changelog_as_markdown(rst: str) -> str: """Get the latest changelog entry as hacked up Markdown.""" for pattern, repl in create_rst_replacements(): rst = re.sub(pattern, repl, rst, flags=re.M | re.DOTALL) md = rst2md(rst) # order bullet points in each of the lists alphabetically to # improve readability return order_bullet_points(md) @click.group() def cli(): pass @cli.command() @click.argument("version", type=Version, callback=validate_new_version) def bump(version: Version) -> None: """Bump the version in project files.""" bump_version(version) @cli.command() def changelog(): """Get the most recent version's changelog as Markdown.""" if changelog := get_changelog_contents(): try: print(changelog_as_markdown(changelog)) except ValueError as e: raise click.exceptions.UsageError(str(e)) if __name__ == "__main__": cli() ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "beets" version = "2.7.1" description = "music tagger and library organizer" authors = ["Adrian Sampson <adrian@radbox.org>"] maintainers = ["Serene-Arc"] license = "MIT" readme = "README.rst" homepage = "https://beets.io/" repository = "https://github.com/beetbox/beets" documentation = "https://beets.readthedocs.io/en/stable/" classifiers = [ "Topic :: Multimedia :: Sound/Audio", "Topic :: Multimedia :: Sound/Audio :: Players :: MP3", "License :: OSI Approved :: MIT License", "Environment :: Console", "Environment :: Web Environment", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ { include = "beets" }, { include = "beetsplug" }, ] include = [ # extra files to include in the sdist { path = "docs", format = "sdist" }, { path = "extra", format = "sdist" }, { path = "man/**/*", format = "sdist" }, { path = "test/*.py", format = "sdist" }, { path = "test/rsrc/**/*", format = "sdist" }, ] exclude = ["docs/_build", "docs/modd.conf", "docs/**/*.css"] [tool.poetry.urls] Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" [tool.poetry.dependencies] python = ">=3.10,<4" colorama = { version = "*", markers = "sys_platform == 'win32'" } confuse = ">=2.2.0" jellyfish = "*" lap = ">=0.5.12" mediafile = ">=0.12.0" numpy = [ { python = "<3.13", version = ">=2.0.2" }, { python = ">=3.13", version = ">=2.3.4" }, ] packaging = ">=24.0" platformdirs = ">=3.5.0" pyyaml = "*" requests = ">=2.32.5" requests-ratelimiter = ">=0.7.0" typing_extensions = "*" unidecode = ">=1.3.6" beautifulsoup4 = { version = "*", optional = true } dbus-python = { version = "*", optional = true } flask = { version = "*", optional = true } flask-cors = { version = "*", optional = true } langdetect = { version = "*", optional = true } librosa = { version = ">=0.11", optional = true } scipy = [ # for librosa { python = "<3.13", version = ">=1.13.1", optional = true }, { python = ">=3.13", version = ">=1.16.1", optional = true }, ] numba = [ # for librosa { python = "<3.13", version = ">=0.60", optional = true }, { python = ">=3.13", version = ">=0.62.1", optional = true }, ] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } py7zr = { version = "*", optional = true } pyacoustid = { version = "*", optional = true } PyGObject = { version = "*", optional = true } pylast = { version = "*", optional = true } python-mpd2 = { version = ">=0.4.2", optional = true } python3-discogs-client = { version = ">=2.3.15", optional = true } pyxdg = { version = "*", optional = true } rarfile = { version = "*", optional = true } reflink = { version = "*", optional = true } resampy = { version = ">=0.4.3", optional = true } requests-oauthlib = { version = ">=0.6.1", optional = true } soco = { version = "*", optional = true } docutils = { version = ">=0.20.1", optional = true } pydata-sphinx-theme = { version = "*", optional = true } sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } sphinx-toolbox = { version = ">=4.1.0", optional = true } titlecase = { version = "^2.4.1", optional = true } [tool.poetry.group.test.dependencies] beautifulsoup4 = "*" codecov = ">=2.1.13" flask = "*" langdetect = "*" pylast = "*" pytest = "*" pytest-cov = "*" pytest-flask = "*" python-mpd2 = "*" python3-discogs-client = ">=2.3.15" py7zr = "*" pyxdg = "*" rarfile = "*" requests-mock = ">=1.12.1" requests_oauthlib = "*" responses = ">=0.3.0" titlecase = "^2.4.1" [tool.poetry.group.lint.dependencies] docstrfmt = ">=2.0.2" ruff = ">=0.13.0" sphinx-lint = ">=1.0.0" [tool.poetry.group.typing.dependencies] mypy = "*" types-beautifulsoup4 = "*" types-docutils = ">=0.22.2.20251006" types-Flask-Cors = "*" types-Pillow = "*" types-PyYAML = "*" types-requests = "*" types-urllib3 = "*" [tool.poetry.group.release.dependencies] click = ">=8.1.7" tomli = ">=2.0.1" [tool.poetry.extras] # inline comments note required external / non-python dependencies absubmit = ["requests"] # extractor binary from https://acousticbrainz.org/download aura = ["flask", "flask-cors", "Pillow"] autobpm = ["librosa", "resampy"] # badfiles # mp3val and flac beatport = ["requests-oauthlib"] bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0 chroma = ["pyacoustid"] # chromaprint or fpcalc # convert # ffmpeg docs = [ "docutils", "pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton", "sphinx-toolbox", ] discogs = ["python3-discogs-client"] embedart = ["Pillow"] # ImageMagick embyupdate = ["requests"] fetchart = ["beautifulsoup4", "langdetect", "Pillow", "requests"] import = ["py7zr", "rarfile"] # ipfs # go-ipfs # keyfinder # KeyFinder kodiupdate = ["requests"] lastgenre = ["pylast"] lastimport = ["pylast"] lyrics = ["beautifulsoup4", "langdetect", "requests"] metasync = ["dbus-python"] mpdstats = ["python-mpd2"] plexupdate = ["requests"] reflink = ["reflink"] replaygain = [ "PyGObject", ] # python-gi and GStreamer 1.0+ or mp3gain/aacgain or Python Audio Tools or ffmpeg scrub = ["mutagen"] sonosupdate = ["soco"] titlecase = ["titlecase"] thumbnails = ["Pillow", "pyxdg"] web = ["flask", "flask-cors"] [tool.poetry.scripts] beet = "beets.ui:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.pipx-install] poethepoet = ">=0.26" poetry = ">=1.8,<2" [tool.poe.tasks.build] help = "Build the package" shell = """ make -C docs man rm -rf man mv docs/_build/man . poetry build """ [tool.poe.tasks.bump] help = "Bump project version and update relevant files" cmd = "python ./extra/release.py bump $version" args = { version = { help = "The new version to set", positional = true, required = true } } [tool.poe.tasks.changelog] help = "Print the latest version's changelog in Markdown" cmd = "python ./extra/release.py changelog" [tool.poe.tasks.check-docs-links] help = "Check the documentation for broken URLs" cmd = "make -C docs linkcheck" [tool.poe.tasks.check-format] help = "Check the code for style issues" cmd = "ruff format --check --diff" [tool.poe.tasks.check-types] help = "Check the code for typing issues. Accepts mypy options." cmd = "mypy" [tool.poe.tasks.docs] help = "Build documentation" args = [{ name = "COMMANDS", positional = true, multiple = true, default = "html" }] cmd = "make -C docs $COMMANDS" [tool.poe.tasks.format] help = "Format the codebase" cmd = "ruff format --config=pyproject.toml" [tool.poe.tasks.format-docs] help = "Format the documentation" cmd = "docstrfmt --preserve-adornments docs *.rst" [tool.poe.tasks.lint] help = "Check the code for linting issues. Accepts ruff options." cmd = "ruff check --config=pyproject.toml" [tool.poe.tasks.lint-docs] help = "Lint the documentation" interpreter = "bash" shell = """ set -o pipefail files=$(git ls-files '*.rst') grep -Eno ' `[^`][^`]+`[^_]' $files | sed 's/ .*/ Use double backticks for inline literal (double-backticks-required)/' && failed=1 sphinx-lint --enable all --disable default-role $files || failed=1 exit ${failed:-0} """ [tool.poe.tasks.update-dependencies] help = "Update dependencies to their latest versions." cmd = "poetry update -vv" [tool.poe.tasks.test] help = "Run tests with pytest" cmd = "pytest $OPTS" env.OPTS.default = "-p no:cov" [tool.poe.tasks.test-with-coverage] help = "Run tests and record coverage" ref = "test" # record coverage in beets and beetsplug packages # save xml for coverage upload to coveralls # save html report for local dev use # measure coverage across logical branches # show which tests cover specific lines in the code (see the HTML report) env.OPTS = """ --cov=beets --cov=beetsplug --cov-report=xml:.reports/coverage.xml --cov-report=html:.reports/html --cov-branch --cov-context=test """ [tool.poe.tasks.check-temp-files] help = "Run each test module one by one and check for leftover temp files" shell = """ setopt nullglob for file in test/**/*.py; do print Temp files created by $file && poe test $file &>/dev/null tempfiles=(/tmp/**/tmp* /tmp/beets/**/*) if (( $#tempfiles )); then print -l $'\t'$^tempfiles rm -r --interactive=never $tempfiles &>/dev/null fi done """ interpreter = "zsh" [tool.docstrfmt] line-length = 80 extend-exclude = [ "docs/_templates/**/*", "docs/api/**/*", "README_kr.rst", ] [tool.ruff] target-version = "py310" line-length = 80 [tool.ruff.lint] future-annotations = true select = [ # "ARG", # flake8-unused-arguments # "C4", # flake8-comprehensions "E", # pycodestyle "F", # pyflakes # "B", # flake8-bugbear "G", # flake8-logging-format "I", # isort "ISC", # flake8-implicit-str-concat "N", # pep8-naming "PT", # flake8-pytest-style "RUF", # ruff "UP", # pyupgrade "TC", # flake8-type-checking "W", # pycodestyle ] ignore = [ "TC006", # no need to quote 'cast's since we use 'from __future__ import annotations' ] [tool.ruff.lint.per-file-ignores] "beets/**" = ["PT"] "test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] "test/util/test_diff.py" = ["E501"] "test/util/test_id_extractors.py" = ["E501"] "test/**" = ["RUF001"] # we use Unicode characters in tests [tool.ruff.lint.isort] split-on-trailing-comma = false [tool.ruff.lint.pycodestyle] max-line-length = 88 [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false parametrize-names-type = "csv" [tool.ruff.lint.flake8-unused-arguments] ignore-variadic-names = true [tool.ruff.lint.pep8-naming] classmethod-decorators = ["cached_classproperty"] extend-ignore-names = ["assert*", "cached_classproperty"] ================================================ FILE: setup.cfg ================================================ [tool:pytest] # do not litter the working directory cache_dir = /tmp/pytest_cache # slightly more verbose output console_output_style = count # pretty-print test names in the Codecov U junit_family = legacy addopts = # show all skipped/failed/xfailed tests in the summary except passed -ra --strict-config --junitxml=.reports/pytest.xml [coverage:run] data_file = .reports/coverage/data branch = true relative_files = true omit = beets/test/* beetsplug/_typing.py [coverage:report] precision = 2 skip_empty = true show_missing = true exclude_also = @atexit.register if TYPE_CHECKING if typing.TYPE_CHECKING raise AssertionError raise NotImplementedError [coverage:html] show_contexts = true [mypy] allow_any_generics = false # FIXME: Would be better to actually type the libraries (if under our control), # or write our own stubs. For now, silence errors ignore_missing_imports = true namespace_packages = true explicit_package_bases = true # Temporary, until we decide on a mypy # config for all files. [[mypy-beets.plugins]] disallow_untyped_decorators = true check_untyped_defs = true [[mypy-beets.metadata_plugins]] disallow_untyped_decorators = true check_untyped_defs = true ================================================ FILE: test/__init__.py ================================================ # Make python -m testall.py work. ================================================ FILE: test/autotag/__init__.py ================================================ ================================================ FILE: test/autotag/test_autotag.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for autotagging functionality.""" import operator import pytest from beets import autotag from beets.autotag import AlbumInfo, TrackInfo, correct_list_fields from beets.library import Item from beets.test.helper import BeetsTestCase class ApplyTest(BeetsTestCase): def _apply(self, per_disc_numbering=False, artist_credit=False): info = self.info item_info_pairs = list(zip(self.items, info.tracks)) self.config["per_disc_numbering"] = per_disc_numbering self.config["artist_credit"] = artist_credit autotag.apply_metadata(self.info, item_info_pairs) def setUp(self): super().setUp() self.items = [Item(), Item()] self.info = AlbumInfo( tracks=[ TrackInfo( title="title", track_id="dfa939ec-118c-4d0f-84a0-60f3d1e6522c", medium=1, medium_index=1, medium_total=1, index=1, artist="trackArtist", artist_credit="trackArtistCredit", artists_credit=["trackArtistCredit"], artist_sort="trackArtistSort", artists_sort=["trackArtistSort"], ), TrackInfo( title="title2", track_id="40130ed1-a27c-42fd-a328-1ebefb6caef4", medium=2, medium_index=1, index=2, medium_total=1, ), ], artist="albumArtist", artists=["albumArtist", "albumArtist2"], album="album", album_id="7edb51cb-77d6-4416-a23c-3a8c2994a2c7", artist_id="a6623d39-2d8e-4f70-8242-0a9553b91e50", artists_ids=None, artist_credit="albumArtistCredit", artists_credit=["albumArtistCredit1", "albumArtistCredit2"], artist_sort=None, artists_sort=["albumArtistSort", "albumArtistSort2"], albumtype="album", va=True, mediums=2, data_source="MusicBrainz", year=2013, month=12, day=18, genres=["Rock", "Pop"], ) common_expected = { "album": "album", "albumartist_credit": "albumArtistCredit", "albumartist_sort": "", "albumartist": "albumArtist", "albumartists": ["albumArtist", "albumArtist2"], "albumartists_credit": [ "albumArtistCredit1", "albumArtistCredit2", ], "albumartists_sort": ["albumArtistSort", "albumArtistSort2"], "albumtype": "album", "albumtypes": ["album"], "comp": True, "disctotal": 2, "mb_albumartistid": "a6623d39-2d8e-4f70-8242-0a9553b91e50", "mb_albumartistids": ["a6623d39-2d8e-4f70-8242-0a9553b91e50"], "mb_albumid": "7edb51cb-77d6-4416-a23c-3a8c2994a2c7", "mb_artistid": "a6623d39-2d8e-4f70-8242-0a9553b91e50", "mb_artistids": ["a6623d39-2d8e-4f70-8242-0a9553b91e50"], "tracktotal": 2, "year": 2013, "month": 12, "day": 18, "genres": ["Rock", "Pop"], } self.expected_tracks = [ { **common_expected, "artist": "trackArtist", "artists": ["albumArtist", "albumArtist2"], "artist_credit": "trackArtistCredit", "artist_sort": "trackArtistSort", "artists_credit": ["trackArtistCredit"], "artists_sort": ["trackArtistSort"], "disc": 1, "mb_trackid": "dfa939ec-118c-4d0f-84a0-60f3d1e6522c", "title": "title", "track": 1, }, { **common_expected, "artist": "albumArtist", "artists": ["albumArtist", "albumArtist2"], "artist_credit": "albumArtistCredit", "artist_sort": "", "artists_credit": [ "albumArtistCredit1", "albumArtistCredit2", ], "artists_sort": ["albumArtistSort", "albumArtistSort2"], "disc": 2, "mb_trackid": "40130ed1-a27c-42fd-a328-1ebefb6caef4", "title": "title2", "track": 2, }, ] def test_autotag_items(self): self._apply() keys = self.expected_tracks[0].keys() get_values = operator.itemgetter(*keys) applied_data = [ dict(zip(keys, get_values(dict(i)))) for i in self.items ] assert applied_data == self.expected_tracks def test_per_disc_numbering(self): self._apply(per_disc_numbering=True) assert self.items[0].track == 1 assert self.items[1].track == 1 assert self.items[0].tracktotal == 1 assert self.items[1].tracktotal == 1 def test_artist_credit_prefers_artist_over_albumartist_credit(self): self.info.tracks[0].update(artist="oldArtist", artist_credit=None) self._apply(artist_credit=True) assert self.items[0].artist == "oldArtist" def test_artist_credit_falls_back_to_albumartist(self): self.info.artist_credit = None self._apply(artist_credit=True) assert self.items[1].artist == "albumArtist" def test_date_only_zeroes_month_and_day(self): self.items = [Item(year=1, month=2, day=3)] self.info.update(year=2013, month=None, day=None) self._apply() assert self.items[0].year == 2013 assert self.items[0].month == 0 assert self.items[0].day == 0 def test_missing_date_applies_nothing(self): self.items = [Item(year=1, month=2, day=3)] self.info.update(year=None, month=None, day=None) self._apply() assert self.items[0].year == 1 assert self.items[0].month == 2 assert self.items[0].day == 3 @pytest.mark.parametrize( "single_field,list_field", [ ("mb_artistid", "mb_artistids"), ("mb_albumartistid", "mb_albumartistids"), ("albumtype", "albumtypes"), ], ) @pytest.mark.parametrize( "single_value,list_value", [ (None, []), (None, ["1"]), (None, ["1", "2"]), ("1", []), ("1", ["1"]), ("1", ["1", "2"]), ("1", ["2", "1"]), ], ) def test_correct_list_fields( single_field, list_field, single_value, list_value ): """Ensure that the first value in a list field matches the single field.""" data = {single_field: single_value, list_field: list_value} item = Item(**data) correct_list_fields(item) single_val, list_val = item[single_field], item[list_field] assert (not single_val and not list_val) or single_val == list_val[0] ================================================ FILE: test/autotag/test_distance.py ================================================ import re import pytest from beets.autotag import AlbumInfo, TrackInfo from beets.autotag.distance import ( Distance, distance, string_dist, track_distance, ) from beets.library import Item from beets.metadata_plugins import MetadataSourcePlugin, get_penalty from beets.plugins import BeetsPlugin _p = pytest.param class TestDistance: @pytest.fixture(autouse=True, scope="class") def setup_config(self, config): config["match"]["distance_weights"]["data_source"] = 2.0 config["match"]["distance_weights"]["album"] = 4.0 config["match"]["distance_weights"]["medium"] = 2.0 @pytest.fixture def dist(self): return Distance() def test_add(self, dist): dist.add("add", 1.0) assert dist._penalties == {"add": [1.0]} @pytest.mark.parametrize( "key, args_with_expected", [ ( "equality", [ (("ghi", ["abc", "def", "ghi"]), [0.0]), (("xyz", ["abc", "def", "ghi"]), [0.0, 1.0]), (("abc", re.compile(r"ABC", re.I)), [0.0, 1.0, 0.0]), ], ), ("expr", [((True,), [1.0]), ((False,), [1.0, 0.0])]), ( "number", [ ((1, 1), [0.0]), ((1, 2), [0.0, 1.0]), ((2, 1), [0.0, 1.0, 1.0]), ((-1, 2), [0.0, 1.0, 1.0, 1.0, 1.0, 1.0]), ], ), ( "priority", [ (("abc", "abc"), [0.0]), (("def", ["abc", "def"]), [0.0, 0.5]), (("gh", ["ab", "cd", "ef", re.compile("GH", re.I)]), [0.0, 0.5, 0.75]), # noqa: E501 (("xyz", ["abc", "def"]), [0.0, 0.5, 0.75, 1.0]), ], ), ( "ratio", [ ((25, 100), [0.25]), ((10, 5), [0.25, 1.0]), ((-5, 5), [0.25, 1.0, 0.0]), ((5, 0), [0.25, 1.0, 0.0, 0.0]), ], ), ( "string", [ (("abc", "bcd"), [2 / 3]), (("abc", None), [2 / 3, 1]), ((None, None), [2 / 3, 1, 0]), ], ), ], ) # fmt: skip def test_add_methods(self, dist, key, args_with_expected): method = getattr(dist, f"add_{key}") for arg_set, expected in args_with_expected: method(key, *arg_set) assert dist._penalties[key] == expected def test_distance(self, dist): dist.add("album", 0.5) dist.add("media", 0.25) dist.add("media", 0.75) assert dist.distance == 0.5 assert dist.max_distance == 6.0 assert dist.raw_distance == 3.0 assert dist["album"] == 1 / 3 assert dist["media"] == 1 / 6 def test_operators(self, dist): dist.add("data_source", 0.0) dist.add("album", 0.5) dist.add("medium", 0.25) dist.add("medium", 0.75) assert len(dist) == 2 assert list(dist) == [("album", 0.2), ("medium", 0.2)] assert dist == 0.4 assert dist < 1.0 assert dist > 0.0 assert dist - 0.4 == 0.0 assert 0.4 - dist == 0.0 assert float(dist) == 0.4 def test_penalties_sort(self, dist): dist.add("album", 0.1875) dist.add("medium", 0.75) assert dist.items() == [("medium", 0.25), ("album", 0.125)] # Sort by key if distance is equal. dist = Distance() dist.add("album", 0.375) dist.add("medium", 0.75) assert dist.items() == [("album", 0.25), ("medium", 0.25)] def test_update(self, dist): dist1 = dist dist1.add("album", 0.5) dist1.add("media", 1.0) dist2 = Distance() dist2.add("album", 0.75) dist2.add("album", 0.25) dist2.add("media", 0.05) dist1.update(dist2) assert dist1._penalties == { "album": [0.5, 0.75, 0.25], "media": [1.0, 0.05], } class TestTrackDistance: @pytest.fixture(scope="class") def info(self): return TrackInfo(title="title", artist="artist") @pytest.mark.parametrize( "title, artist, expected_penalty", [ _p("title", "artist", False, id="identical"), _p("title", "Various Artists", False, id="tolerate-va"), _p("title", "different artist", True, id="different-artist"), _p("different title", "artist", True, id="different-title"), ], ) def test_track_distance(self, info, title, artist, expected_penalty): item = Item(artist=artist, title=title) dist = track_distance(item, info, incl_artist=True) assert bool(dist) == expected_penalty, dist._penalties class TestAlbumDistance: @pytest.fixture(scope="class") def items(self): return [ Item( title=title, track=track, artist="artist", album="album", length=1, ) for title, track in [("one", 1), ("two", 2), ("three", 3)] ] @pytest.fixture def get_dist(self, items): def inner(info: AlbumInfo): return distance(items, info, list(zip(items, info.tracks))) return inner @pytest.fixture def info(self, items): return AlbumInfo( artist="artist", album="album", tracks=[ TrackInfo( title=i.title, artist=i.artist, index=i.track, length=i.length, ) for i in items ], va=False, ) def test_identical_albums(self, get_dist, info): assert get_dist(info) == 0 def test_incomplete_album(self, get_dist, info): info.tracks.pop(2) assert 0 < float(get_dist(info)) < 0.2 def test_overly_complete_album(self, get_dist, info): info.tracks.append( Item(index=4, title="four", artist="artist", length=1) ) assert 0 < float(get_dist(info)) < 0.2 @pytest.mark.parametrize("va", [True, False]) def test_albumartist(self, get_dist, info, va): info.artist = "another artist" info.va = va assert bool(get_dist(info)) is not va def test_comp_no_track_artists(self, get_dist, info): # Some VA releases don't have track artists (incomplete metadata). info.artist = "another artist" info.va = True for track in info.tracks: track.artist = None assert get_dist(info) == 0 def test_comp_track_artists_do_not_match(self, get_dist, info): info.va = True info.tracks[0].artist = "another artist" assert get_dist(info) != 0 def test_tracks_out_of_order(self, get_dist, info): tracks = info.tracks tracks[1].title, tracks[2].title = tracks[2].title, tracks[1].title assert 0 < float(get_dist(info)) < 0.2 def test_two_medium_release(self, get_dist, info): info.tracks[0].medium_index = 1 info.tracks[1].medium_index = 2 info.tracks[2].medium_index = 1 assert get_dist(info) == 0 class TestStringDistance: @pytest.mark.parametrize( "string1, string2", [ ("Some String", "Some String"), ("Some String", "Some.String!"), ("Some String", "sOME sTring"), ("My Song (EP)", "My Song"), ("The Song Title", "Song Title, The"), ("A Song Title", "Song Title, A"), ("An Album Title", "Album Title, An"), ("", ""), ("Untitled", "[Untitled]"), ("And", "&"), ("\xe9\xe1\xf1", "ean"), ], ) def test_matching_distance(self, string1, string2): assert string_dist(string1, string2) == 0.0 def test_different_distance(self): assert string_dist("Some String", "Totally Different") != 0.0 @pytest.mark.parametrize( "string1, string2, reference", [ ("XXX Band Name", "The Band Name", "Band Name"), ("One .Two.", "One (Two)", "One"), ("One .Two.", "One [Two]", "One"), ("My Song blah Someone", "My Song feat Someone", "My Song"), ], ) def test_relative_weights(self, string1, string2, reference): assert string_dist(string2, reference) < string_dist(string1, reference) def test_solo_pattern(self): # Just make sure these don't crash. string_dist("The ", "") string_dist("(EP)", "(EP)") string_dist(", An", "") class TestDataSourceDistance: MATCH = 0.0 MISMATCH = 0.125 @pytest.fixture(autouse=True) def setup(self, monkeypatch, penalty, weight, multiple_data_sources): monkeypatch.setitem(Distance._weights, "data_source", weight) get_penalty.cache_clear() class TestMetadataSourcePlugin(MetadataSourcePlugin): def album_for_id(self, *args, **kwargs): ... def track_for_id(self, *args, **kwargs): ... def candidates(self, *args, **kwargs): ... def item_candidates(self, *args, **kwargs): ... # We use BeetsPlugin here to check if our compatibility layer # for pre 2.4.0 MetadataPlugins is working as expected # TODO: Replace BeetsPlugin with TestMetadataSourcePlugin in v3.0.0 with pytest.deprecated_call(): class OriginalPlugin(BeetsPlugin): data_source = "Original" class OtherPlugin(TestMetadataSourcePlugin): @property def data_source_mismatch_penalty(self): return penalty monkeypatch.setattr( "beets.metadata_plugins.find_metadata_source_plugins", lambda: ( [OriginalPlugin(), OtherPlugin()] if multiple_data_sources else [OtherPlugin()] ), ) @pytest.mark.parametrize( "item,info,penalty,weight,multiple_data_sources,expected_distance", [ _p("Original", "Original", 0.5, 1.0, True, MATCH, id="match"), _p("Original", "Other", 0.5, 1.0, True, MISMATCH, id="mismatch"), _p("Other", "Original", 0.5, 1.0, True, MISMATCH, id="mismatch"), _p("Original", "unknown", 0.5, 1.0, True, MISMATCH, id="mismatch-unknown"), _p("Original", None, 0.5, 1.0, True, MISMATCH, id="mismatch-no-info"), _p(None, "Other", 0.5, 1.0, True, MISMATCH, id="mismatch-no-original-multiple-sources"), # noqa: E501 _p(None, "Other", 0.5, 1.0, False, MATCH, id="match-no-original-but-single-source"), # noqa: E501 _p("unknown", "unknown", 0.5, 1.0, True, MATCH, id="match-unknown"), _p("Original", "Other", 1.0, 1.0, True, 0.25, id="mismatch-max-penalty"), _p("Original", "Other", 0.5, 5.0, True, 0.3125, id="mismatch-high-weight"), _p("Original", "Other", 0.0, 1.0, True, MATCH, id="match-no-penalty"), _p("Original", "Other", 0.5, 0.0, True, MATCH, id="match-no-weight"), ], ) # fmt: skip def test_distance(self, item, info, expected_distance): item = Item(data_source=item) info = TrackInfo(data_source=info, title="") dist = track_distance(item, info) assert dist.distance == expected_distance ================================================ FILE: test/autotag/test_hooks.py ================================================ import pytest from beets.autotag.hooks import Info @pytest.mark.parametrize( "genre, expected_genres", [ ("Rock", ("Rock",)), ("Rock; Alternative", ("Rock", "Alternative")), ], ) def test_genre_deprecation(genre, expected_genres): with pytest.warns( DeprecationWarning, match="The 'genre' parameter is deprecated" ): assert tuple(Info(genre=genre).genres) == expected_genres ================================================ FILE: test/autotag/test_match.py ================================================ from typing import ClassVar import pytest from beets import metadata_plugins from beets.autotag import AlbumInfo, TrackInfo, match from beets.library import Item class TestAssignment: A = "one" B = "two" C = "three" @pytest.fixture(autouse=True) def config(self, config): config["match"]["track_length_grace"] = 10 config["match"]["track_length_max"] = 30 @pytest.mark.parametrize( # 'expected' is a tuple of expected (mapping, extra_items, extra_tracks) "item_titles, track_titles, expected", [ # items ordering gets corrected ([A, C, B], [A, B, C], ({A: A, B: B, C: C}, [], [])), # unmatched tracks are returned as 'extra_tracks' # the first track is unmatched ([B, C], [A, B, C], ({B: B, C: C}, [], [A])), # the middle track is unmatched ([A, C], [A, B, C], ({A: A, C: C}, [], [B])), # the last track is unmatched ([A, B], [A, B, C], ({A: A, B: B}, [], [C])), # unmatched items are returned as 'extra_items' ([A, C, B], [A, C], ({A: A, C: C}, [B], [])), ], ) def test_assign_tracks(self, item_titles, track_titles, expected): expected_mapping, expected_extra_items, expected_extra_tracks = expected items = [Item(title=title) for title in item_titles] tracks = [TrackInfo(title=title) for title in track_titles] item_info_pairs, extra_items, extra_tracks = match.assign_items( items, tracks ) assert ( {i.title: t.title for i, t in item_info_pairs}, [i.title for i in extra_items], [t.title for t in extra_tracks], ) == (expected_mapping, expected_extra_items, expected_extra_tracks) def test_order_works_when_track_names_are_entirely_wrong(self): # A real-world test case contributed by a user. def item(i, length): return Item( artist="ben harper", album="burn to shine", title=f"ben harper - Burn to Shine {i}", track=i, length=length, ) items = [] items.append(item(1, 241.37243007106997)) items.append(item(2, 342.27781704375036)) items.append(item(3, 245.95070222338137)) items.append(item(4, 472.87662515485437)) items.append(item(5, 279.1759535763187)) items.append(item(6, 270.33333768012)) items.append(item(7, 247.83435613222923)) items.append(item(8, 216.54504531525072)) items.append(item(9, 225.72775379800484)) items.append(item(10, 317.7643606963552)) items.append(item(11, 243.57001238834192)) items.append(item(12, 186.45916150485752)) def info(index, title, length): return TrackInfo(title=title, length=length, index=index) trackinfo = [] trackinfo.append(info(1, "Alone", 238.893)) trackinfo.append(info(2, "The Woman in You", 341.44)) trackinfo.append(info(3, "Less", 245.59999999999999)) trackinfo.append(info(4, "Two Hands of a Prayer", 470.49299999999999)) trackinfo.append(info(5, "Please Bleed", 277.86599999999999)) trackinfo.append(info(6, "Suzie Blue", 269.30599999999998)) trackinfo.append(info(7, "Steal My Kisses", 245.36000000000001)) trackinfo.append(info(8, "Burn to Shine", 214.90600000000001)) trackinfo.append(info(9, "Show Me a Little Shame", 224.0929999999999)) trackinfo.append(info(10, "Forgiven", 317.19999999999999)) trackinfo.append(info(11, "Beloved One", 243.733)) trackinfo.append(info(12, "In the Lord's Arms", 186.13300000000001)) expected = list(zip(items, trackinfo)), [], [] assert match.assign_items(items, trackinfo) == expected class TestTagMultipleDataSources: @pytest.fixture def shared_track_id(self): return "track-12345" @pytest.fixture def shared_album_id(self): return "album-12345" @pytest.fixture(autouse=True) def _setup_plugins(self, monkeypatch, shared_album_id, shared_track_id): class StubPlugin: data_source: ClassVar[str] data_source_mismatch_penalty = 0 @property def track(self): return TrackInfo( artist="Artist", title="Title", track_id=shared_track_id, data_source=self.data_source, ) @property def album(self): return AlbumInfo( [self.track], artist="Albumartist", album="Album", album_id=shared_album_id, data_source=self.data_source, ) def albums_for_ids(self, *_): yield self.album def tracks_for_ids(self, *_): yield self.track def candidates(self, *_, **__): yield self.album def item_candidates(self, *_, **__): yield self.track class DeezerPlugin(StubPlugin): data_source = "Deezer" class DiscogsPlugin(StubPlugin): data_source = "Discogs" monkeypatch.setattr( metadata_plugins, "find_metadata_source_plugins", lambda: [DeezerPlugin(), DiscogsPlugin()], ) def check_proposal(self, proposal): sources = [ candidate.info.data_source for candidate in proposal.candidates ] assert len(sources) == 2 assert set(sources) == {"Discogs", "Deezer"} def test_search_album_ids(self, shared_album_id): _, _, proposal = match.tag_album([Item()], search_ids=[shared_album_id]) self.check_proposal(proposal) def test_search_album_current_id(self, shared_album_id): _, _, proposal = match.tag_album([Item(mb_albumid=shared_album_id)]) self.check_proposal(proposal) def test_search_track_ids(self, shared_track_id): proposal = match.tag_item(Item(), search_ids=[shared_track_id]) self.check_proposal(proposal) def test_search_track_current_id(self, shared_track_id): proposal = match.tag_item(Item(mb_trackid=shared_track_id)) self.check_proposal(proposal) ================================================ FILE: test/conftest.py ================================================ import importlib.util import inspect import os from functools import cache import pytest from beets.autotag.distance import Distance from beets.dbcore.query import Query from beets.test._common import DummyIO from beets.test.helper import ConfigMixin from beets.util import cached_classproperty @cache def _is_importable(modname: str) -> bool: return bool(importlib.util.find_spec(modname)) def skip_marked_items(items: list[pytest.Item], marker_name: str, reason: str): for item in (i for i in items if i.get_closest_marker(marker_name)): test_name = item.nodeid.split("::", 1)[-1] item.add_marker(pytest.mark.skip(f"{reason}: {test_name}")) def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ): if not os.environ.get("INTEGRATION_TEST") == "true": skip_marked_items( items, "integration_test", "INTEGRATION_TEST=1 required" ) if not os.environ.get("LYRICS_UPDATED") == "true": skip_marked_items( items, "on_lyrics_update", "No change in lyrics source code" ) for item in items: if marker := item.get_closest_marker("requires_import"): force_ci = marker.kwargs.get("force_ci", True) if ( force_ci and os.environ.get("GITHUB_ACTIONS") == "true" # only apply this to our repository, to allow other projects to # run tests without installing all dependencies and os.environ.get("GITHUB_REPOSITORY", "") == "beetbox/beets" ): continue modname = marker.args[0] if not _is_importable(modname): test_name = item.nodeid.split("::", 1)[-1] item.add_marker( pytest.mark.skip( f"{modname!r} is not installed: {test_name}" ) ) def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", "integration_test: mark a test as an integration test", ) config.addinivalue_line( "markers", "on_lyrics_update: run test only when lyrics source code changes", ) config.addinivalue_line( "markers", ( "requires_import(module, force_ci=True): run test only if module" " is importable (use force_ci=False to allow CI to skip the test too)" ), ) def pytest_make_parametrize_id(config, val, argname): """Generate readable test identifiers for pytest parametrized tests. Provides custom string representations for: - Query classes/instances: use class name - Lambda functions: show abbreviated source - Other values: use standard repr() """ if inspect.isclass(val) and issubclass(val, Query): return val.__name__ if inspect.isfunction(val) and val.__name__ == "<lambda>": return inspect.getsource(val).split("lambda")[-1][:30] return repr(val) def pytest_assertrepr_compare(op, left, right): if isinstance(left, Distance) or isinstance(right, Distance): return [f"Comparing Distance: {float(left)} {op} {float(right)}"] @pytest.fixture(autouse=True) def clear_cached_classproperty(): cached_classproperty.cache.clear() @pytest.fixture(scope="module") def config(): """Provide a fresh beets configuration for a module, when requested.""" return ConfigMixin().config @pytest.fixture def io( request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, capteesys: pytest.CaptureFixture[str], ) -> DummyIO: """Fixture for tests that need controllable stdin and captured stdout. This fixture builds a per-test ``DummyIO`` helper and exposes it to the test. When used on a test class, it attaches the helper as ``self.io`` attribute to make it available to all test methods, including ``unittest.TestCase``-based ones. """ io = DummyIO(monkeypatch, capteesys) if request.instance: request.instance.io = io return io @pytest.fixture def is_importable(): """Fixture that provides a function to check if a module can be imported.""" return _is_importable ================================================ FILE: test/library/__init__.py ================================================ ================================================ FILE: test/library/test_migrations.py ================================================ import textwrap import pytest from beets.dbcore import types from beets.library import migrations from beets.library.models import Album, Item from beets.test.helper import TestHelper class TestMultiGenreFieldMigration: @pytest.fixture def helper(self, monkeypatch): # do not apply migrations upon library initialization monkeypatch.setattr("beets.library.library.Library._migrations", ()) # add genre field to both models to make sure this column is created monkeypatch.setattr( "beets.library.models.Item._fields", {**Item._fields, "genre": types.STRING}, ) monkeypatch.setattr( "beets.library.models.Album._fields", {**Album._fields, "genre": types.STRING}, ) monkeypatch.setattr( "beets.library.models.Album.item_keys", {*Album.item_keys, "genre"}, ) helper = TestHelper() helper.setup_beets() # and now configure the migrations to be tested monkeypatch.setattr( "beets.library.library.Library._migrations", ((migrations.MultiGenreFieldMigration, (Item, Album)),), ) yield helper helper.teardown_beets() def test_migrate(self, helper: TestHelper): helper.config["lastgenre"]["separator"] = " - " expected_item_genres = [] for genre, initial_genres, expected_genres in [ # already existing value is not overwritten ("Item Rock", ("Ignored",), ("Ignored",)), ("", (), ()), ("Rock", (), ("Rock",)), # multiple genres are split on one of default separators ("Item Rock; Alternative", (), ("Item Rock", "Alternative")), # multiple genres are split the first (lastgenre) separator ONLY ("Item - Rock, Alternative", (), ("Item", "Rock, Alternative")), ]: helper.add_item(genre=genre, genres=initial_genres) expected_item_genres.append(expected_genres) unmigrated_album = helper.add_album( genre="Album Rock / Alternative", genres=[] ) expected_item_genres.append(("Album Rock", "Alternative")) helper.lib._migrate() actual_item_genres = [tuple(i.genres) for i in helper.lib.items()] assert actual_item_genres == expected_item_genres unmigrated_album.load() assert unmigrated_album.genres == ["Album Rock", "Alternative"] # remove cached initial db tables data del helper.lib.db_tables assert helper.lib.migration_exists("multi_genre_field", "items") assert helper.lib.migration_exists("multi_genre_field", "albums") class TestLyricsMetadataInFlexFieldsMigration: @pytest.fixture def helper(self, monkeypatch): # do not apply migrations upon library initialization monkeypatch.setattr("beets.library.library.Library._migrations", ()) helper = TestHelper() helper.setup_beets() # and now configure the migrations to be tested monkeypatch.setattr( "beets.library.library.Library._migrations", ((migrations.LyricsMetadataInFlexFieldsMigration, (Item,)),), ) yield helper helper.teardown_beets() def test_migrate(self, helper: TestHelper, is_importable): lyrics_item = helper.add_item( lyrics=textwrap.dedent(""" [00:00.00] Some synced lyrics / Quelques paroles synchronisées [00:00.50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées Source: https://lrclib.net/api/1/""") ) instrumental_lyrics_item = helper.add_item(lyrics="[Instrumental]") helper.lib._migrate() lyrics_item.load() assert lyrics_item.lyrics == textwrap.dedent( """ [00:00.00] Some synced lyrics / Quelques paroles synchronisées [00:00.50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées""" ) assert lyrics_item.lyrics_backend == "lrclib" assert lyrics_item.lyrics_url == "https://lrclib.net/api/1/" if is_importable("langdetect"): assert lyrics_item.lyrics_language == "EN" assert lyrics_item.lyrics_translation_language == "FR" else: with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_translation_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_backend with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_url with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_language with pytest.raises(AttributeError): instrumental_lyrics_item.lyrics_translation_language # remove cached initial db tables data del helper.lib.db_tables assert helper.lib.migration_exists( "lyrics_metadata_in_flex_fields", "items" ) ================================================ FILE: test/plugins/__init__.py ================================================ ================================================ FILE: test/plugins/conftest.py ================================================ from __future__ import annotations from typing import TYPE_CHECKING import pytest import requests if TYPE_CHECKING: from requests_mock import Mocker @pytest.fixture def requests_mock(requests_mock, monkeypatch) -> Mocker: """Use plain session wherever MB requests are mocked. This avoids rate limiting requests to speed up tests. """ monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.create_session", lambda _: requests.Session(), ) return requests_mock ================================================ FILE: test/plugins/lyrics_pages.py ================================================ from __future__ import annotations import os import textwrap from typing import NamedTuple from urllib.parse import urlparse import pytest def xfail_on_ci(msg: str) -> pytest.MarkDecorator: return pytest.mark.xfail( bool(os.environ.get("GITHUB_ACTIONS")), reason=msg, raises=AssertionError, ) class LyricsPage(NamedTuple): """Lyrics page representation for integrated tests.""" url: str lyrics: str artist: str = "The Beatles" track_title: str = "Lady Madonna" language: str = "EN" url_title: str | None = None # only relevant to the Google backend marks: list[str] = [] # markers for pytest.param # noqa: RUF012 def __str__(self) -> str: """Return name of this test case.""" return f"{self.backend}-{self.source}" @classmethod def make(cls, url, lyrics, *args, **kwargs): return cls(url, textwrap.dedent(lyrics).strip(), *args, **kwargs) @property def root_url(self) -> str: return urlparse(self.url).netloc @property def source(self) -> str: return self.root_url.replace("www.", "").split(".")[0] @property def backend(self) -> str: if (source := self.source) in {"genius", "tekstowo", "lrclib"}: return source return "google" lyrics_pages = [ LyricsPage.make( "http://www.absolutelyrics.com/lyrics/view/the_beatles/lady_madonna", """ The Beatles - Lady Madonna Lady Madonna, children at your feet. Wonder how you manage to make ends meet. Who finds the money? When you pay the rent? Did you think that money was heaven sent? Friday night arrives without a suitcase. Sunday morning creep in like a nun. Monday's child has learned to tie his bootlace. See how they run. Lady Madonna, baby at your breast. Wonder how you manage to feed the rest. See how they run. Lady Madonna, lying on the bed, Listen to the music playing in your head. Tuesday afternoon is never ending. Wednesday morning papers didn't come. Thursday night you stockings needed mending. See how they run. Lady Madonna, children at your feet. Wonder how you manage to make ends meet. """, url_title="Lady Madonna Lyrics :: The Beatles - Absolute Lyrics", ), LyricsPage.make( "https://www.azlyrics.com/lyrics/beatles/ladymadonna.html", """ Lady Madonna, children at your feet Wonder how you manage to make ends meet Who finds the money when you pay the rent Did you think that money was Heaven sent? Friday night arrives without a suitcase Sunday morning creeping like a nun Monday's child has learned to tie his bootlace See how they run Lady Madonna, baby at your breast Wonders how you manage to feed the rest? See how they run Lady Madonna lying on the bed Listen to the music playing in your head Tuesday afternoon is never ending Wednesday morning papers didn't come Thursday night your stockings needed mending See how they run Lady Madonna, children at your feet Wonder how you manage to make ends meet """, url_title="The Beatles - Lady Madonna Lyrics | AZLyrics.com", marks=[xfail_on_ci("AZLyrics is blocked by Cloudflare")], ), LyricsPage.make( "https://www.dainuzodziai.lt/m/mergaites-nori-mylet-atlanta/", """ Jos nesuspėja skriet paskui vėją Bangos į krantą grąžina jas vėl Jos karštą saulę paliesti norėjo Ant kranto palikę visas negandas Bet jos nori mylėt Jos nenori liūdėt Leisk mergaitėms mylėt Kaip jos moka mylėt Koks vakaras šiltas ir nieko nestinga Veidus apšviečia žaisminga šviesa Jos buvo laimingos prie jūros kur liko Tik vėjas išmokęs visas jų dainas """, artist="Atlanta", track_title="Mergaitės Nori Mylėt", language="LT", url_title="Mergaitės nori mylėt – Atlanta | Dainų Žodžiai", marks=[xfail_on_ci("Expired SSL certificate")], ), LyricsPage.make( "https://genius.com/The-beatles-lady-madonna-lyrics", """ [Verse 1: Paul McCartney] Lady Madonna, children at your feet Wonder how you manage to make ends meet Who finds the money when you pay the rent? Did you think that money was heaven sent? [Bridge: Paul McCartney, Paul McCartney, John Lennon & George Harrison] Friday night arrives without a suitcase Sunday morning creeping like a nun Monday's child has learned to tie his bootlace See how they run [Verse 2: Paul McCartney] Lady Madonna, baby at your breast Wonders how you manage to feed the rest [Tenor Saxophone Solo: Ronnie Scott] [Bridge: John Lennon & George Harrison, Paul McCartney, John Lennon & George Harrison] Pa-pa-pa-pa, pa-pa-pa-pa-pa Pa-pa-pa-pa-pa, pa-pa-pa, pa-pa, pa-pa Pa-pa-pa-pa, pa-pa-pa-pa-pa See how they run [Verse 3: Paul McCartney] Lady Madonna, lying on the bed Listen to the music playing in your head [Bridge: Paul McCartney, John Lennon & George Harrison, Paul McCartney, John Lennon & George Harrison] Tuesday afternoon is never ending (Pa-pa-pa-pa, pa-pa-pa-pa-pa) Wednesday morning, papers didn't come (Pa-pa-pa-pa-pa, pa-pa-pa, pa-pa, pa-pa) Thursday night, your stockings needed mending (Pa-pa-pa-pa, pa-pa-pa-pa-pa) See how they run [Verse 4: Paul McCartney] Lady Madonna, children at your feet Wonder how you manage to make ends meet """, # noqa: E501 marks=[xfail_on_ci("Genius returns 403 FORBIDDEN in CI")], ), LyricsPage.make( "https://www.lacoccinelle.net/259956-the-beatles-lady-madonna.html", """ Lady Madonna Mademoiselle Madonna Lady Madonna, children at your feet. Mademoiselle Madonna, les enfants à vos pieds Wonder how you manage to make ends meet. Je me demande comment vous vous débrouillez pour joindre les deux bouts Who finds the money, when you pay the rent ? Qui trouve l'argent pour payer le loyer ? Did you think that money was heaven sent ? Pensiez-vous que ça allait être envoyé du ciel ? Friday night arrives without a suitcase. Le vendredi soir arrive sans bagages Sunday morning creeping like a nun. Le dimanche matin elle se traine comme une nonne Monday's child has learned to tie his bootlace. Lundi l'enfant a appris à lacer ses chaussures See how they run. Regardez comme ils courent Lady Madonna, baby at your breast. Mademoiselle Madonna, le bébé a votre sein Wonder how you manage to feed the rest. Je me demande comment vous faites pour nourrir le reste Lady Madonna, lying on the bed, Mademoiselle Madonna, couchée sur votre lit Listen to the music playing in your head. Vous écoutez la musique qui joue dans votre tête Tuesday afternoon is never ending. Le mardi après-midi n'en finit pas Wednesday morning papers didn't come. Le mercredi matin les journaux ne sont pas arrivés Thursday night you stockings needed mending. Jeudi soir, vos bas avaient besoin d'être réparés See how they run. Regardez comme ils filent Lady Madonna, children at your feet. Mademoiselle Madonna, les enfants à vos pieds Wonder how you manage to make ends meet. Je me demande comment vous vous débrouillez pour joindre les deux bouts """, url_title="Paroles et traduction The Beatles : Lady Madonna - paroles de chanson", # noqa: E501 language="FR", ), LyricsPage.make( # note that this URL needs to be followed with a slash, otherwise it # redirects to the same URL with a slash "https://www.letras.mus.br/the-beatles/275/", """ Lady Madonna Children at your feet Wonder how you manage To make ends meet Who finds the money When you pay the rent? Did you think that money Was Heaven sent? Friday night arrives without a suitcase Sunday morning creeping like a nun Monday's child has learned To tie his bootlace See how they run Lady Madonna Baby at your breast Wonders how you manage To feed the rest See how they run Lady Madonna Lying on the bed Listen to the music Playing in your head Tuesday afternoon is neverending Wednesday morning papers didn't come Thursday night your stockings Needed mending See how they run Lady Madonna Children at your feet Wonder how you manage To make ends meet """, url_title="Lady Madonna - The Beatles - LETRAS.MUS.BR", ), LyricsPage.make( "https://lrclib.net/api/get/19648857", """ [00:08.35] Lady Madonna, children at your feet [00:12.85] Wonder how you manage to make ends meet [00:17.56] Who finds the money when you pay the rent [00:21.78] Did you think that money was heaven sent [00:26.22] Friday night arrives without a suitcase [00:30.02] Sunday morning creeping like a nun [00:34.53] Monday's child has learned to tie his bootlace [00:39.18] See how they run [00:43.33] Lady Madonna, baby at your breast [00:48.50] Wonders how you manage to feed the rest [00:52.54] [01:01.32] Ba-ba, ba-ba, ba-ba, ba-ba-ba [01:05.03] Ba-ba, ba-ba, ba-ba, ba, ba-ba, ba-ba [01:09.58] Ba-ba, ba-ba, ba-ba, ba-ba-ba [01:14.27] See how they run [01:19.05] Lady Madonna, lying on the bed [01:22.99] Listen to the music playing in your head [01:27.92] [01:36.33] Tuesday afternoon is never ending [01:40.47] Wednesday morning papers didn't come [01:44.76] Thursday night your stockings needed mending [01:49.35] See how they run [01:53.73] Lady Madonna, children at your feet [01:58.65] Wonder how you manage to make ends meet [02:06.04] """, ), LyricsPage.make( "https://www.lyricsmania.com/lady_madonna_lyrics_the_beatles.html", """ Lady Madonna, children at your feet. Wonder how you manage to make ends meet. Who finds the money? When you pay the rent? Did you think that money was heaven sent? Friday night arrives without a suitcase. Sunday morning creep in like a nun. Monday's child has learned to tie his bootlace. See how they run. Lady Madonna, baby at your breast. Wonder how you manage to feed the rest. See how they run. Lady Madonna, lying on the bed, Listen to the music playing in your head. Tuesday afternoon is never ending. Wednesday morning papers didn't come. Thursday night you stockings needed mending. See how they run. Lady Madonna, children at your feet. Wonder how you manage to make ends meet. """, url_title="The Beatles - Lady Madonna Lyrics", ), LyricsPage.make( "https://www.lyricsmode.com/lyrics/b/beatles/mother_natures_son.html", """ Born a poor young country boy, Mother Nature's son All day long I'm sitting singing songs for everyone Sit beside a mountain stream, see her waters rise Listen to the pretty sound of music as she flies Doo doo doo doo doo doo doo doo doo doo doo Doo doo doo doo doo doo doo doo doo Doo doo doo Find me in my field of grass, Mother Nature's son Swaying daises sing a lazy song beneath the sun Doo doo doo doo doo doo doo doo doo doo doo Doo doo doo doo doo doo doo doo doo Doo doo doo doo doo doo Yeah yeah yeah Mm mm mm mm mm mm mm Mm mm mm, ooh ooh ooh Mm mm mm mm mm mm mm Mm mm mm mm, wah wah wah Wah, Mother Nature's son """, artist="The Beatles", track_title="Mother Nature's Son", url_title=( "Mother Nature's Son lyrics by The Beatles - original song full" " text. Official Mother Nature's Son lyrics, 2025 version" " | LyricsMode.com" ), ), LyricsPage.make( "https://www.lyricsontop.com/amy-winehouse-songs/jazz-n-blues-lyrics.html", """ It's all gone within two days, Follow my father His extravagant ways So, if I got it out I'll spend it all. Heading In parkway, til I hit the wall. I cross my fingers at the cash machine, As I check my balance I kiss the screen, I love it when it says I got the main's To got o Miss Sixty and pick up my jeans. Money ever last long Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. Money ever last long, Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. Standing to the … bar today, Waiting impatient to throw my cash away, For that Russian JD and coke Had the drinks all night, and now I am bold But that's cool, cause I can buy more from you. And I didn't forgot about that 50 Compton, Tell you what? My fancy's coming through I'll take you at shopping, can you wait til next June? Yeah, Money ever last long Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. Money ever last long, Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. (Instrumental Break) Money ever last long Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. Money ever last long, Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. Money ever last long, Had to fight what's wrong, Blow it all on bags and shoes, Jazz n' blues. """, artist="Amy Winehouse", track_title="Jazz N' Blues", url_title="Amy Winehouse - Jazz N' Blues lyrics complete", ), LyricsPage.make( "https://www.musica.com/letras.asp?letra=59862", """ Lady Madonna, baby at your breast Wonders how you manage to feed the rest See how they run Lady Madonna lying on the bed Listen to the music playing in your head Tuesday afternoon is never ending Wednesday morning papers didn't come Thursday night your stockings needed mending See how they run Lady Madonna, children at your feet Wonder how you manage to make ends meet """, url_title="Lady Madonna - Letra - The Beatles - Musica.com", ), LyricsPage.make( "https://www.paroles.net/the-beatles/paroles-lady-madonna", """ Lady Madonna, children at your feet. Wonder how you manage to make ends meet. Who finds the money? When you pay the rent? Did you think that money was heaven sent? Friday night arrives without a suitcase. Sunday morning creep in like a nun. Monday's child has learned to tie his bootlace. See how they run. Lady Madonna, baby at your breast. Wonders how you manage to feed the rest. See how they run. Lady Madonna, lying on the bed, Listen to the music playing in your head. Tuesday afternoon is never ending. Wednesday morning papers didn't come. Thursday night your stockings needed mending. See how they run. Lady Madonna, children at your feet. Wonder how you manage to make ends meet. """, url_title="Paroles Lady Madonna par The Beatles - Lyrics - Paroles.net", ), LyricsPage.make( "https://www.songlyrics.com/the-beatles/lady-madonna-lyrics", """ Lady Madonna, children at your feet Wonder how you manage to make ends meet Who finds the money? When you pay the rent? Did you think that money was Heaven sent? Friday night arrives without a suitcase Sunday morning creep in like a nun Monday's child has learned to tie his bootlace See how they run Lady Madonna, baby at your breast Wonder how you manage to feed the rest See how they run Lady Madonna, lying on the bed Listen to the music playing in your head Tuesday afternoon is never ending Wednesday morning papers didn't come Thursday night you stockings needed mending See how they run Lady Madonna, children at your feet Wonder how you manage to make ends meet """, url_title="THE BEATLES - LADY MADONNA LYRICS", marks=[xfail_on_ci("Songlyrics is blocked by Cloudflare")], ), LyricsPage.make( "https://sweetslyrics.com/the-beatles/lady-madonna-lyrics", """ Lady Madonna, children at your feet. Wonder how you manage to make ends meet. Who finds the money when you pay the rent? Did you think that money was heaven sent? Friday night arrives without a suitcase. Sunday morning creeping like a nun. Monday's child has learned to tie his bootlace. See how they run... Lady Madonna, baby at your breast. Wonders how you manage to feed the rest. (Sax solo) See how they run... Lady Madonna, lying on the bed. Listen to the music playing in your head. Tuesday afternoon is never ending. Wednesday morning papers didn't come. Thursday night your stockings needed mending. See how they run... Lady Madonna, children at your feet. Wonder how you manage to make ends meet. """, url_title="The Beatles - Lady Madonna", marks=[xfail_on_ci("Sweetslyrics also fails with 403 FORBIDDEN in CI")], ), LyricsPage.make( "https://www.tekstowo.pl/piosenka,the_beatles,lady_madonna.html", """ Lady Madonna, Children at your feet Wonder how you manage to make ends meet. Who find the money When you pay the rent? Did you think that money was Heaven sent? Friday night arrives without a suitcase Sunday morning creeping like a nun Monday's child has learned to tie his bootlace See how they run Lady Madonna Baby at your breast Wonders how you manage to feed the rest See how they run Lady Madonna Lying on the bed Listen to the music playing in your head Tuesday afternoon is neverending Wednesday morning papers didn't come Thursday night your stockings needed mending See how they run Lady Madonna, Children at your feet Wonder how you manage to make ends meet """, marks=[pytest.mark.xfail(reason="Tekstowo seems to be broken again")], ), ] ================================================ FILE: test/plugins/test_acousticbrainz.py ================================================ # This file is part of beets. # Copyright 2016, Nathan Dwek. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'acousticbrainz' plugin.""" import json import os.path import unittest from beets.test._common import RSRC from beetsplug.acousticbrainz import ABSCHEME, AcousticPlugin class MapDataToSchemeTest(unittest.TestCase): def test_basic(self): ab = AcousticPlugin() data = {"key 1": "value 1", "key 2": "value 2"} scheme = {"key 1": "attribute 1", "key 2": "attribute 2"} mapping = set(ab._map_data_to_scheme(data, scheme)) assert mapping == { ("attribute 1", "value 1"), ("attribute 2", "value 2"), } def test_recurse(self): ab = AcousticPlugin() data = { "key": "value", "group": { "subkey": "subvalue", "subgroup": {"subsubkey": "subsubvalue"}, }, } scheme = { "key": "attribute 1", "group": { "subkey": "attribute 2", "subgroup": {"subsubkey": "attribute 3"}, }, } mapping = set(ab._map_data_to_scheme(data, scheme)) assert mapping == { ("attribute 1", "value"), ("attribute 2", "subvalue"), ("attribute 3", "subsubvalue"), } def test_composite(self): ab = AcousticPlugin() data = {"key 1": "part 1", "key 2": "part 2"} scheme = {"key 1": ("attribute", 0), "key 2": ("attribute", 1)} mapping = set(ab._map_data_to_scheme(data, scheme)) assert mapping == {("attribute", "part 1 part 2")} def test_realistic(self): ab = AcousticPlugin() data_path = os.path.join(RSRC, b"acousticbrainz/data.json") with open(data_path) as res: data = json.load(res) mapping = set(ab._map_data_to_scheme(data, ABSCHEME)) expected = { ("chords_key", "A"), ("average_loudness", 0.815025985241), ("mood_acoustic", 0.415711194277), ("chords_changes_rate", 0.0445116683841), ("tonal", 0.874250173569), ("mood_sad", 0.299694597721), ("bpm", 162.532119751), ("gender", "female"), ("initial_key", "A minor"), ("chords_number_rate", 0.00194468453992), ("mood_relaxed", 0.123632438481), ("chords_scale", "minor"), ("voice_instrumental", "instrumental"), ("key_strength", 0.636936545372), ("genre_rosamerica", "roc"), ("mood_party", 0.234383180737), ("mood_aggressive", 0.0779221653938), ("danceable", 0.143928021193), ("rhythm", "VienneseWaltz"), ("mood_electronic", 0.339881360531), ("mood_happy", 0.0894767045975), ("moods_mirex", "Cluster3"), ("timbre", "bright"), } assert mapping == expected ================================================ FILE: test/plugins/test_advancedrewrite.py ================================================ # This file is part of beets. # Copyright 2023, Max Rumpf. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the advancedrewrite plugin for various configurations.""" import pytest from beets.test.helper import PluginTestCase from beets.ui import UserError PLUGIN_NAME = "advancedrewrite" class AdvancedRewritePluginTest(PluginTestCase): plugin = "advancedrewrite" preload_plugin = False def test_simple_rewrite_example(self): with self.configure_plugin( [{"artist ODD EYE CIRCLE": "이달의 소녀 오드아이써클"}] ): item = self.add_item( artist="ODD EYE CIRCLE", albumartist="ODD EYE CIRCLE", ) assert item.artist == "이달의 소녀 오드아이써클" def test_advanced_rewrite_example(self): with self.configure_plugin( [ { "match": "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022", # noqa: E501 "replacements": { "artist": "이달의 소녀 오드아이써클", "artist_sort": "LOONA / ODD EYE CIRCLE", }, }, ] ): item_a = self.add_item( artist="ODD EYE CIRCLE", artist_sort="ODD EYE CIRCLE", mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c", year=2017, ) item_b = self.add_item( artist="ODD EYE CIRCLE", artist_sort="ODD EYE CIRCLE", mb_artistid="dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c", year=2023, ) # Assert that all replacements were applied to item_a assert "이달의 소녀 오드아이써클" == item_a.artist assert "LOONA / ODD EYE CIRCLE" == item_a.artist_sort assert "LOONA / ODD EYE CIRCLE" == item_a.albumartist_sort # Assert that no replacements were applied to item_b assert "ODD EYE CIRCLE" == item_b.artist def test_advanced_rewrite_example_with_multi_valued_field(self): with self.configure_plugin( [ { "match": "artist:배유빈 feat. 김미현", "replacements": {"artists": ["유빈", "미미"]}, }, ] ): item = self.add_item( artist="배유빈 feat. 김미현", artists=["배유빈", "김미현"], ) assert item.artists == ["유빈", "미미"] def test_fail_when_replacements_empty(self): with ( pytest.raises( UserError, match="Advanced rewrites must have at least one replacement", ), self.configure_plugin([{"match": "artist:A", "replacements": {}}]), ): pass def test_fail_when_rewriting_single_valued_field_with_list(self): with ( pytest.raises( UserError, match="Field artist is not a multi-valued field but a list was given: C, D", # noqa: E501 ), self.configure_plugin( [ { "match": "artist:'A & B'", "replacements": {"artist": ["C", "D"]}, }, ] ), ): pass def test_combined_rewrite_example(self): with self.configure_plugin( [ {"artist A": "B"}, {"match": "album:'C'", "replacements": {"artist": "D"}}, ] ): item = self.add_item(artist="A", albumartist="A") assert item.artist == "B" item = self.add_item(artist="C", albumartist="C", album="C") assert item.artist == "D" ================================================ FILE: test/plugins/test_albumtypes.py ================================================ # This file is part of beets. # Copyright 2021, Edgars Supe. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'albumtypes' plugin.""" from __future__ import annotations from typing import TYPE_CHECKING from beets.test.helper import PluginTestCase from beetsplug.albumtypes import AlbumTypesPlugin from beetsplug.musicbrainz import VARIOUS_ARTISTS_ID if TYPE_CHECKING: from collections.abc import Sequence class AlbumTypesPluginTest(PluginTestCase): """Tests for albumtypes plugin.""" plugin = "albumtypes" def test_renames_types(self): """Tests if the plugin correctly renames the specified types.""" self._set_config( types=[("ep", "EP"), ("remix", "Remix")], ignore_va=[], bracket="()" ) album = self._create_album(album_types=["ep", "remix"]) subject = AlbumTypesPlugin() result = subject._atypes(album) assert "(EP)(Remix)" == result return def test_returns_only_specified_types(self): """Tests if the plugin returns only non-blank types given in config.""" self._set_config( types=[("ep", "EP"), ("soundtrack", "")], ignore_va=[], bracket="()" ) album = self._create_album(album_types=["ep", "remix", "soundtrack"]) subject = AlbumTypesPlugin() result = subject._atypes(album) assert "(EP)" == result def test_respects_type_order(self): """Tests if the types are returned in the same order as config.""" self._set_config( types=[("remix", "Remix"), ("ep", "EP")], ignore_va=[], bracket="()" ) album = self._create_album(album_types=["ep", "remix"]) subject = AlbumTypesPlugin() result = subject._atypes(album) assert "(Remix)(EP)" == result return def test_ignores_va(self): """Tests if the specified type is ignored for VA albums.""" self._set_config( types=[("ep", "EP"), ("soundtrack", "OST")], ignore_va=["ep"], bracket="()", ) album = self._create_album( album_types=["ep", "soundtrack"], artist_id=VARIOUS_ARTISTS_ID ) subject = AlbumTypesPlugin() result = subject._atypes(album) assert "(OST)" == result def test_respects_defaults(self): """Tests if the plugin uses the default values if config not given.""" album = self._create_album( album_types=[ "ep", "single", "soundtrack", "live", "compilation", "remix", ], artist_id=VARIOUS_ARTISTS_ID, ) subject = AlbumTypesPlugin() result = subject._atypes(album) assert "[EP][Single][OST][Live][Remix]" == result def _set_config( self, types: Sequence[tuple[str, str]], ignore_va: Sequence[str], bracket: str, ): self.config["albumtypes"]["types"] = types self.config["albumtypes"]["ignore_va"] = ignore_va self.config["albumtypes"]["bracket"] = bracket def _create_album(self, album_types: Sequence[str], artist_id: str = "0"): return self.add_album( albumtypes=album_types, mb_albumartistid=artist_id ) ================================================ FILE: test/plugins/test_art.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the album art fetchers.""" from __future__ import annotations import os import shutil import unittest from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch import confuse import pytest import responses from beets import config, importer, logging, util from beets.autotag import AlbumInfo, AlbumMatch from beets.test import _common from beets.test.helper import ( BeetsTestCase, CleanupModulesMixin, FetchImageHelper, capture_log, ) from beets.util import syspath from beets.util.artresizer import ArtResizer from beetsplug import fetchart logger = logging.getLogger("beets.test_art") if TYPE_CHECKING: from collections.abc import Iterator, Sequence from beets.library import Album class Settings: """Used to pass settings to the ArtSources when the plugin isn't fully instantiated. """ def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class DummyRemoteArtSource(fetchart.RemoteArtSource): NAME = "Dummy Art Source" ID = "dummy" def get( self, album: Album, plugin: fetchart.FetchArtPlugin, paths: None | Sequence[bytes], ) -> Iterator[fetchart.Candidate]: return iter(()) class UseThePlugin(CleanupModulesMixin, BeetsTestCase): modules = (fetchart.__name__, ArtResizer.__module__) def setUp(self): super().setUp() self.plugin = fetchart.FetchArtPlugin() class FetchImageTestCase(FetchImageHelper, UseThePlugin): pass class CAAHelper: """Helper mixin for mocking requests to the Cover Art Archive.""" MBID_RELASE = "rid" MBID_GROUP = "rgid" RELEASE_URL = f"coverartarchive.org/release/{MBID_RELASE}" GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}" RELEASE_URL = f"https://{RELEASE_URL}" GROUP_URL = f"https://{GROUP_URL}" RESPONSE_RELEASE = """{ "images": [ { "approved": false, "back": false, "comment": "GIF", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.gif", "thumbnails": { "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rid/12345-250.jpg", "500": "http://coverartarchive.org/release/rid/12345-500.jpg", "large": "http://coverartarchive.org/release/rid/12345-500.jpg", "small": "http://coverartarchive.org/release/rid/12345-250.jpg" }, "types": [ "Front" ] }, { "approved": false, "back": false, "comment": "", "edit": 12345, "front": false, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.jpg", "thumbnails": { "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rid/12345-250.jpg", "500": "http://coverartarchive.org/release/rid/12345-500.jpg", "large": "http://coverartarchive.org/release/rid/12345-500.jpg", "small": "http://coverartarchive.org/release/rid/12345-250.jpg" }, "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/releaseid" }""" RESPONSE_RELEASE_WITHOUT_THUMBNAILS = """{ "images": [ { "approved": false, "back": false, "comment": "GIF", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.gif", "types": [ "Front" ] }, { "approved": false, "back": false, "comment": "", "edit": 12345, "front": false, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.jpg", "thumbnails": { "large": "http://coverartarchive.org/release/rgid/12345-500.jpg", "small": "http://coverartarchive.org/release/rgid/12345-250.jpg" }, "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/releaseid" }""" RESPONSE_GROUP = """{ "images": [ { "approved": false, "back": false, "comment": "", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/releaseid/12345.jpg", "thumbnails": { "1200": "http://coverartarchive.org/release/rgid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rgid/12345-250.jpg", "500": "http://coverartarchive.org/release/rgid/12345-500.jpg", "large": "http://coverartarchive.org/release/rgid/12345-500.jpg", "small": "http://coverartarchive.org/release/rgid/12345-250.jpg" }, "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/release-id" }""" RESPONSE_GROUP_WITHOUT_THUMBNAILS = """{ "images": [ { "approved": false, "back": false, "comment": "", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/releaseid/12345.jpg", "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/release-id" }""" def mock_caa_response(self, url, json): responses.add( responses.GET, url, body=json, content_type="application/json" ) class FetchImageTest(FetchImageTestCase): URL = "http://example.com/test.jpg" def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b"arttest") self.source = DummyRemoteArtSource(logger, self.plugin.config) self.settings = Settings(maxwidth=0) self.candidate = fetchart.Candidate( logger, self.source.ID, url=self.URL ) def test_invalid_type_returns_none(self): self.mock_response(self.URL, "image/watercolour") self.source.fetch_image(self.candidate, self.settings) assert self.candidate.path is None def test_jpeg_type_returns_path(self): self.mock_response(self.URL, "image/jpeg") self.source.fetch_image(self.candidate, self.settings) assert self.candidate.path is not None def test_extension_set_by_content_type(self): self.mock_response(self.URL, "image/png") self.source.fetch_image(self.candidate, self.settings) assert os.path.splitext(self.candidate.path)[1] == b".png" assert Path(os.fsdecode(self.candidate.path)).exists() def test_does_not_rely_on_server_content_type(self): self.mock_response(self.URL, "image/jpeg", "image/png") self.source.fetch_image(self.candidate, self.settings) assert os.path.splitext(self.candidate.path)[1] == b".png" assert Path(os.fsdecode(self.candidate.path)).exists() class FSArtTest(UseThePlugin): def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b"arttest") os.mkdir(syspath(self.dpath)) self.source = fetchart.FileSystem(logger, self.plugin.config) self.settings = Settings( cautious=False, cover_names=("art",), fallback=None ) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, b"a.jpg")) candidate = next(self.source.get(None, self.settings, [self.dpath])) assert candidate.path == os.path.join(self.dpath, b"a.jpg") def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, b"a.jpg")) _common.touch(os.path.join(self.dpath, b"art.jpg")) candidate = next(self.source.get(None, self.settings, [self.dpath])) assert candidate.path == os.path.join(self.dpath, b"art.jpg") def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, b"a.txt")) with pytest.raises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, b"a.jpg")) self.settings.cautious = True with pytest.raises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_configured_fallback_is_used(self): fallback = os.path.join(self.temp_dir, b"a.jpg") _common.touch(fallback) self.settings.fallback = fallback candidate = next(self.source.get(None, self.settings, [self.dpath])) assert candidate.path == fallback def test_empty_dir(self): with pytest.raises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_precedence_amongst_correct_files(self): images = [b"front-cover.jpg", b"front.jpg", b"back.jpg"] paths = [os.path.join(self.dpath, i) for i in images] for p in paths: _common.touch(p) self.settings.cover_names = ["cover", "front", "back"] candidates = [ candidate.path for candidate in self.source.get(None, self.settings, [self.dpath]) ] assert candidates == paths @patch("os.path.samefile") def test_is_candidate_fallback_os_error(self, mock_samefile): mock_samefile.side_effect = OSError("os error") fallback = os.path.join(self.temp_dir, b"a.jpg") self.plugin.fallback = fallback candidate = fetchart.Candidate(logger, self.source.ID, fallback) result = self.plugin._is_candidate_fallback(candidate) mock_samefile.assert_called_once() assert not result class CombinedTest(FetchImageTestCase, CAAHelper): ASIN = "xxxx" MBID = "releaseid" AMAZON_URL = f"https://images.amazon.com/images/P/{ASIN}.01.LZZZZZZZ.jpg" AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}" def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b"arttest") os.mkdir(syspath(self.dpath)) def test_main_interface_returns_amazon_art(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) assert candidate is not None def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() candidate = self.plugin.art_for_album(album, None) assert candidate is None def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, b"art.jpg")) self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) assert candidate is not None assert candidate.path == os.path.join(self.dpath, b"art.jpg") def test_main_interface_falls_back_to_amazon(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) assert candidate is not None assert not candidate.path.startswith(self.dpath) def test_main_interface_tries_amazon_before_aao(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) assert len(responses.calls) == 1 assert responses.calls[0].request.url == self.AMAZON_URL def test_main_interface_falls_back_to_aao(self): self.mock_response(self.AMAZON_URL, content_type="text/html") album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) assert responses.calls[-1].request.url == self.AAO_URL def test_main_interface_uses_caa_when_mbid_available(self): self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE) self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP) self.mock_response( "http://coverartarchive.org/release/rid/12345.gif", content_type="image/gif", ) self.mock_response( "http://coverartarchive.org/release/rid/12345.jpg", content_type="image/jpeg", ) album = _common.Bag( mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP, asin=self.ASIN, ) candidate = self.plugin.art_for_album(album, None) assert candidate is not None assert len(responses.calls) == 3 assert responses.calls[0].request.url == self.RELEASE_URL def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) self.plugin.art_for_album(album, None, local_only=True) assert len(responses.calls) == 0 def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, b"art.jpg")) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album( album, [self.dpath], local_only=True ) assert candidate is not None assert candidate.path == os.path.join(self.dpath, b"art.jpg") assert len(responses.calls) == 0 class AAOTest(UseThePlugin): ASIN = "xxxx" AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}" def setUp(self): super().setUp() self.source = fetchart.AlbumArtOrg(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, body): responses.add(responses.GET, url, body=body, content_type="text/html") def test_aao_scraper_finds_image(self): body = """ <br /> <a href=\"TARGET_URL\" title=\"View larger image\" class=\"thickbox\" style=\"color: #7E9DA2; text-decoration:none;\"> <img src=\"http://www.albumart.org/images/zoom-icon.jpg\" alt=\"View larger image\" width=\"17\" height=\"15\" border=\"0\"/></a> """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) candidate = next(self.source.get(album, self.settings, [])) assert candidate.url == "TARGET_URL" def test_aao_scraper_returns_no_result_when_no_image_present(self): self.mock_response(self.AAO_URL, "blah blah") album = _common.Bag(asin=self.ASIN) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) class ITunesStoreTest(UseThePlugin): def setUp(self): super().setUp() self.source = fetchart.ITunesStore(logger, self.plugin.config) self.settings = Settings() self.album = _common.Bag(albumartist="some artist", album="some album") @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add( responses.GET, url, body=json, content_type="application/json" ) def test_itunesstore_finds_image(self): json = """{ "results": [ { "artistName": "some artist", "collectionName": "some album", "artworkUrl100": "url_to_the_image" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) assert candidate.url == "url_to_the_image" assert candidate.match == fetchart.MetadataMatch.EXACT def test_itunesstore_no_result(self): json = '{"results": []}' self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "got no results" with capture_log("beets.test_art") as logs: with pytest.raises(StopIteration): next(self.source.get(self.album, self.settings, [])) assert expected in logs[1] def test_itunesstore_requestexception(self): responses.add( responses.GET, fetchart.ITunesStore.API_URL, json={"error": "not found"}, status=404, ) expected = "iTunes search failed: 404 Client Error" with capture_log("beets.test_art") as logs: with pytest.raises(StopIteration): next(self.source.get(self.album, self.settings, [])) assert expected in logs[1] def test_itunesstore_fallback_match(self): json = """{ "results": [ { "collectionName": "some album", "artworkUrl100": "url_to_the_image" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) assert candidate.url == "url_to_the_image" assert candidate.match == fetchart.MetadataMatch.FALLBACK def test_itunesstore_returns_result_without_artwork(self): json = """{ "results": [ { "artistName": "some artist", "collectionName": "some album" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "Malformed itunes candidate" with capture_log("beets.test_art") as logs: with pytest.raises(StopIteration): next(self.source.get(self.album, self.settings, [])) assert expected in logs[1] def test_itunesstore_returns_no_result_when_error_received(self): json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "not found in json. Fields are" with capture_log("beets.test_art") as logs: with pytest.raises(StopIteration): next(self.source.get(self.album, self.settings, [])) assert expected in logs[1] def test_itunesstore_returns_no_result_with_malformed_response(self): json = """bla blup""" self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "Could not decode json response:" with capture_log("beets.test_art") as logs: with pytest.raises(StopIteration): next(self.source.get(self.album, self.settings, [])) assert expected in logs[1] class GoogleImageTest(UseThePlugin): def setUp(self): super().setUp() self.source = fetchart.GoogleImages(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add( responses.GET, url, body=json, content_type="application/json" ) def test_google_art_finds_image(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"items": [{"link": "url_to_the_image"}]}' self.mock_response(fetchart.GoogleImages.URL, json) candidate = next(self.source.get(album, self.settings, [])) assert candidate.url == "url_to_the_image" def test_google_art_returns_no_result_when_error_received(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.GoogleImages.URL, json) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) def test_google_art_returns_no_result_with_malformed_response(self): album = _common.Bag(albumartist="some artist", album="some album") json = """bla blup""" self.mock_response(fetchart.GoogleImages.URL, json) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) class CoverArtArchiveTest(UseThePlugin, CAAHelper): def setUp(self): super().setUp() self.source = fetchart.CoverArtArchive(logger, self.plugin.config) self.settings = Settings(maxwidth=0) @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def test_caa_finds_image(self): album = _common.Bag( mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP ) self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE) self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP) candidates = list(self.source.get(album, self.settings, [])) assert len(candidates) == 3 assert len(responses.calls) == 2 assert responses.calls[0].request.url == self.RELEASE_URL def test_fetchart_uses_caa_pre_sized_maxwidth_thumbs(self): # CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px # We only test with one of them here maxwidth = 1200 self.settings = Settings(maxwidth=maxwidth) album = _common.Bag( mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP ) self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE) self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP) candidates = list(self.source.get(album, self.settings, [])) assert len(candidates) == 3 for candidate in candidates: assert f"-{maxwidth}.jpg" in candidate.url def test_caa_finds_image_if_maxwidth_is_set_and_thumbnails_is_empty(self): # CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px # We only test with one of them here maxwidth = 1200 self.settings = Settings(maxwidth=maxwidth) album = _common.Bag( mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP ) self.mock_caa_response( self.RELEASE_URL, self.RESPONSE_RELEASE_WITHOUT_THUMBNAILS ) self.mock_caa_response( self.GROUP_URL, self.RESPONSE_GROUP_WITHOUT_THUMBNAILS, ) candidates = list(self.source.get(album, self.settings, [])) assert len(candidates) == 3 for candidate in candidates: assert f"-{maxwidth}.jpg" not in candidate.url class FanartTVTest(UseThePlugin): RESPONSE_MULTIPLE = """{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "albumcover": [ { "id": "24", "url": "http://example.com/1.jpg", "likes": "0" }, { "id": "42", "url": "http://example.com/2.jpg", "likes": "0" }, { "id": "23", "url": "http://example.com/3.jpg", "likes": "0" } ], "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_NO_ART = """{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_ERROR = """{ "status": "error", "error message": "the error message" }""" RESPONSE_MALFORMED = "bla blup" def setUp(self): super().setUp() self.source = fetchart.FanartTV(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add( responses.GET, url, body=json, content_type="application/json" ) def test_fanarttv_finds_image(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_MULTIPLE, ) candidate = next(self.source.get(album, self.settings, [])) assert candidate.url == "http://example.com/1.jpg" def test_fanarttv_returns_no_result_when_error_received(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_ERROR, ) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_returns_no_result_with_malformed_response(self): album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_MALFORMED, ) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_only_other_images(self): # The source used to fail when there were images present, but no cover album = _common.Bag(mb_releasegroupid="thereleasegroupid") self.mock_response( f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid", self.RESPONSE_NO_ART, ) with pytest.raises(StopIteration): next(self.source.get(album, self.settings, [])) @_common.slow_test() class ArtImporterTest(UseThePlugin): def setUp(self): super().setUp() # Mock the album art fetcher to always return our test file. self.art_file = self.temp_dir_path / "tmpcover.jpg" self.art_file.touch() self.old_afa = self.plugin.art_for_album self.afa_response = fetchart.Candidate( logger, source_name="test", path=self.art_file, ) def art_for_album(i, p, local_only=False): return self.afa_response self.plugin.art_for_album = art_for_album # Test library. os.mkdir(syspath(os.path.join(self.libdir, b"album"))) itempath = os.path.join(self.libdir, b"album", b"test.mp3") shutil.copyfile( syspath(os.path.join(_common.RSRC, b"full.mp3")), syspath(itempath), ) self.i = _common.item() self.i.path = itempath self.album = self.lib.add_album([self.i]) self.lib._connection().commit() # The import configuration. self.session = _common.import_session(self.lib) # Import task for the coroutine. self.task = importer.ImportTask(None, None, [self.i]) self.task.is_album = True self.task.album = self.album info = AlbumInfo( album="some album", album_id="albumid", artist="some artist", artist_id="artistid", tracks=[], ) self.task.set_choice(AlbumMatch(0, info, {}, set(), set())) def tearDown(self): super().tearDown() self.plugin.art_for_album = self.old_afa def _fetch_art(self, should_exist): """Execute the fetch_art coroutine for the task and return the album's resulting artpath. ``should_exist`` specifies whether to assert that art path was set (to the correct value) or or that the path was not set. """ # Execute the two relevant parts of the importer. self.plugin.fetch_art(self.session, self.task) self.plugin.assign_art(self.session, self.task) artpath = self.lib.albums()[0].art_filepath if should_exist: assert artpath == self.i.filepath.parent / "cover.jpg" assert artpath.exists() else: assert artpath is None return artpath def test_fetch_art(self): assert not self.lib.albums()[0].artpath self._fetch_art(True) def test_art_not_found(self): self.afa_response = None self._fetch_art(False) def test_no_art_for_singleton(self): self.task.is_album = False self._fetch_art(False) def test_leave_original_file_in_place(self): self._fetch_art(True) assert self.art_file.exists() def test_delete_original_file(self): prev_move = config["import"]["move"].get() try: config["import"]["move"] = True self._fetch_art(True) assert not self.art_file.exists() finally: config["import"]["move"] = prev_move def test_do_not_delete_original_if_already_in_place(self): artdest = os.path.join(os.path.dirname(self.i.path), b"cover.jpg") shutil.copyfile(self.art_file, syspath(artdest)) self.afa_response = fetchart.Candidate( logger, source_name="test", path=artdest, ) self._fetch_art(True) def test_fetch_art_if_imported_file_deleted(self): # See #1126. Test the following scenario: # - Album art imported, `album.artpath` set. # - Imported album art file subsequently deleted (by user or other # program). # `fetchart` should import album art again instead of printing the # message "<album> has album art". self._fetch_art(True) util.remove(self.album.artpath) self.plugin.batch_fetch_art( self.lib, self.lib.albums(), force=False, quiet=False ) assert self.album.art_filepath.exists() class AlbumArtOperationTestCase(UseThePlugin): """Base test case for album art operations. Provides common setup for testing album art processing operations by setting up a mock filesystem source that returns a predefined test image. """ IMAGE_PATH = os.path.join(_common.RSRC, b"abbey-similar.jpg") IMAGE_FILESIZE = os.stat(util.syspath(IMAGE_PATH)).st_size IMAGE_WIDTH = 500 IMAGE_HEIGHT = 490 IMAGE_WIDTH_HEIGHT_DIFF = IMAGE_WIDTH - IMAGE_HEIGHT @classmethod def setUpClass(cls): super().setUpClass() def fs_source_get(_self, album, settings, paths): if paths: yield fetchart.Candidate( logger, source_name=_self.ID, path=cls.IMAGE_PATH ) patch("beetsplug.fetchart.FileSystem.get", fs_source_get).start() cls.addClassCleanup(patch.stopall) def get_album_art(self): return self.plugin.art_for_album(_common.Bag(), [""], True) class AlbumArtOperationConfigurationTest(AlbumArtOperationTestCase): """Check that scale & filesize configuration is respected. Depending on `minwidth`, `enforce_ratio`, `margin_px`, and `margin_percent` configuration the plugin should or should not return an art candidate. """ def test_minwidth(self): self.plugin.minwidth = self.IMAGE_WIDTH / 2 assert self.get_album_art() self.plugin.minwidth = self.IMAGE_WIDTH * 2 assert not self.get_album_art() def test_enforce_ratio(self): self.plugin.enforce_ratio = True assert not self.get_album_art() self.plugin.enforce_ratio = False assert self.get_album_art() def test_enforce_ratio_with_px_margin(self): self.plugin.enforce_ratio = True self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 0.5 assert not self.get_album_art() self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 1.5 assert self.get_album_art() def test_enforce_ratio_with_percent_margin(self): self.plugin.enforce_ratio = True diff_by_width = self.IMAGE_WIDTH_HEIGHT_DIFF / self.IMAGE_WIDTH self.plugin.margin_percent = diff_by_width * 0.5 assert not self.get_album_art() self.plugin.margin_percent = diff_by_width * 1.5 assert self.get_album_art() class AlbumArtPerformOperationTest(AlbumArtOperationTestCase): """Test that the art is resized and deinterlaced if necessary.""" def setUp(self): super().setUp() self.resizer_mock = patch.object( ArtResizer.shared, "resize", return_value=self.IMAGE_PATH ).start() self.deinterlacer_mock = patch.object( ArtResizer.shared, "deinterlace", return_value=self.IMAGE_PATH ).start() def test_resize(self): self.plugin.maxwidth = self.IMAGE_WIDTH / 2 assert self.get_album_art() assert self.resizer_mock.called def test_file_resized(self): self.plugin.max_filesize = self.IMAGE_FILESIZE // 2 assert self.get_album_art() assert self.resizer_mock.called def test_file_not_resized(self): self.plugin.max_filesize = self.IMAGE_FILESIZE assert self.get_album_art() assert not self.resizer_mock.called def test_file_resized_but_not_scaled(self): self.plugin.maxwidth = self.IMAGE_WIDTH * 2 self.plugin.max_filesize = self.IMAGE_FILESIZE // 2 assert self.get_album_art() assert self.resizer_mock.called def test_file_resized_and_scaled(self): self.plugin.maxwidth = self.IMAGE_WIDTH / 2 self.plugin.max_filesize = self.IMAGE_FILESIZE // 2 assert self.get_album_art() assert self.resizer_mock.called def test_deinterlaced(self): self.plugin.deinterlace = True assert self.get_album_art() assert self.deinterlacer_mock.called def test_not_deinterlaced(self): self.plugin.deinterlace = False assert self.get_album_art() assert not self.deinterlacer_mock.called def test_deinterlaced_and_resized(self): self.plugin.maxwidth = self.IMAGE_WIDTH / 2 self.plugin.deinterlace = True assert self.get_album_art() assert self.deinterlacer_mock.called assert self.resizer_mock.called class DeprecatedConfigTest(unittest.TestCase): """While refactoring the plugin, the remote_priority option was deprecated, and a new codepath should translate its effect. Check that it actually does so. """ # If we subclassed UseThePlugin, the configuration change would either be # overwritten by BeetsTestCase or be set after constructing the # plugin object def setUp(self): super().setUp() config["fetchart"]["remote_priority"] = True self.plugin = fetchart.FetchArtPlugin() def test_moves_filesystem_to_end(self): assert isinstance(self.plugin.sources[-1], fetchart.FileSystem) class EnforceRatioConfigTest(unittest.TestCase): """Throw some data at the regexes.""" def _load_with_config(self, values, should_raise): if should_raise: for v in values: config["fetchart"]["enforce_ratio"] = v with pytest.raises(confuse.ConfigValueError): fetchart.FetchArtPlugin() else: for v in values: config["fetchart"]["enforce_ratio"] = v fetchart.FetchArtPlugin() def test_px(self): self._load_with_config("0px 4px 12px 123px".split(), False) self._load_with_config("00px stuff5px".split(), True) def test_percent(self): self._load_with_config("0% 0.00% 5.1% 5% 100%".split(), False) self._load_with_config("00% 1.234% foo5% 100.1%".split(), True) ================================================ FILE: test/plugins/test_aura.py ================================================ from __future__ import annotations import os from http import HTTPStatus from pathlib import Path from typing import TYPE_CHECKING, Any import pytest from beets.test.helper import TestHelper if TYPE_CHECKING: from flask.testing import Client @pytest.fixture(scope="session", autouse=True) def helper(): helper = TestHelper() helper.setup_beets() yield helper helper.teardown_beets() @pytest.fixture(scope="session") def app(helper): from beetsplug.aura import create_app app = create_app() app.config["lib"] = helper.lib return app @pytest.fixture(scope="session") def item(helper): return helper.add_item_fixture( album="Album", title="Title", artist="Artist", albumartist="Album Artist", ) @pytest.fixture(scope="session") def album(helper, item): return helper.lib.add_album([item]) @pytest.fixture(scope="session", autouse=True) def _other_album_and_item(helper): """Add another item and album to prove that filtering works.""" item = helper.add_item_fixture( album="Other Album", title="Other Title", artist="Other Artist", albumartist="Other Album Artist", ) helper.lib.add_album([item]) class TestAuraResponse: @pytest.fixture def get_response_data(self, client: Client, item): """Return a callback accepting `endpoint` and `params` parameters.""" def get(endpoint: str, params: dict[str, str]) -> dict[str, Any] | None: """Add additional `params` and GET the given endpoint. `include` parameter is added to every call to check that the functionality that fetches related entities works. Before returning the response data, ensure that the request is successful. """ response = client.get( endpoint, query_string={"include": "tracks,artists,albums", **params}, ) assert response.status_code == HTTPStatus.OK return response.json return get @pytest.fixture(scope="class") def track_document(self, item, album): return { "type": "track", "id": str(item.id), "attributes": { "album": item.album, "albumartist": item.albumartist, "artist": item.artist, "size": Path(os.fsdecode(item.path)).stat().st_size, "title": item.title, "track": 1, }, "relationships": { "albums": {"data": [{"id": str(album.id), "type": "album"}]}, "artists": {"data": [{"id": item.artist, "type": "artist"}]}, }, } @pytest.fixture(scope="class") def artist_document(self, item): return { "type": "artist", "id": item.artist, "attributes": {"name": item.artist}, "relationships": { "tracks": {"data": [{"id": str(item.id), "type": "track"}]} }, } @pytest.fixture(scope="class") def album_document(self, album): return { "type": "album", "id": str(album.id), "attributes": {"artist": album.albumartist, "title": album.album}, "relationships": { "tracks": {"data": [{"id": str(album.id), "type": "track"}]} }, } def test_tracks( self, get_response_data, item, album_document, artist_document, track_document, ): data = get_response_data("/aura/tracks", {"filter[title]": item.title}) assert data == { "data": [track_document], "included": [artist_document, album_document], } def test_artists( self, get_response_data, item, artist_document, track_document ): data = get_response_data( "/aura/artists", {"filter[artist]": item.artist} ) assert data == {"data": [artist_document], "included": [track_document]} def test_albums( self, get_response_data, album, album_document, track_document ): data = get_response_data("/aura/albums", {"filter[album]": album.album}) assert data == {"data": [album_document], "included": [track_document]} ================================================ FILE: test/plugins/test_autobpm.py ================================================ import pytest from beets.test.helper import ImportHelper, PluginMixin pytestmark = pytest.mark.requires_import("librosa") class TestAutoBPMPlugin(PluginMixin, ImportHelper): plugin = "autobpm" @pytest.fixture(scope="class", name="lib") def fixture_lib(self): self.setup_beets() yield self.lib self.teardown_beets() @pytest.fixture(scope="class") def item(self): return self.add_item_fixture() @pytest.fixture(scope="class") def importer(self, lib): self.import_media = [] self.prepare_album_for_import(1) track = self.import_media[0] track.bpm = None track.save() return self.setup_importer(autotag=False) def test_command(self, lib, item): self.run_command("autobpm", lib=lib) item.load() assert item.bpm == 117 def test_import(self, lib, importer): importer.run() assert lib.items().get().bpm == 117 ================================================ FILE: test/plugins/test_bareasc.py ================================================ # This file is part of beets. # Copyright 2021, Graham R. Cobb. """Tests for the 'bareasc' plugin.""" from beets import logging from beets.test.helper import IOMixin, PluginTestCase class BareascPluginTest(IOMixin, PluginTestCase): """Test bare ASCII query matching.""" plugin = "bareasc" def setUp(self): """Set up test environment for bare ASCII query matching.""" super().setUp() self.log = logging.getLogger("beets.web") self.config["bareasc"]["prefix"] = "#" # Add library elements. Note that self.lib.add overrides any "id=<n>" # and assigns the next free id number. self.add_item(title="with accents", album_id=2, artist="Antonín Dvořák") self.add_item(title="without accents", artist="Antonín Dvorak") self.add_item(title="with umlaut", album_id=2, artist="Brüggen") self.add_item(title="without umlaut or e", artist="Bruggen") self.add_item(title="without umlaut with e", artist="Brueggen") def test_bareasc_search(self): test_cases = [ ( "dvorak", ["without accents"], ), # Normal search, no accents, not using bare-ASCII match. ( "dvořák", ["with accents"], ), # Normal search, with accents, not using bare-ASCII match. ( "#dvorak", ["without accents", "with accents"], ), # Bare-ASCII search, no accents. ( "#dvořák", ["without accents", "with accents"], ), # Bare-ASCII search, with accents. ( "#dvořäk", ["without accents", "with accents"], ), # Bare-ASCII search, with incorrect accent. ( "#Bruggen", ["without umlaut or e", "with umlaut"], ), # Bare-ASCII search, with no umlaut. ( "#Brüggen", ["without umlaut or e", "with umlaut"], ), # Bare-ASCII search, with umlaut. ] for query, expected_titles in test_cases: with self.subTest(query=query, expected_titles=expected_titles): items = self.lib.items(query) assert [item.title for item in items] == expected_titles def test_bareasc_list_output(self): """Bare-ASCII version of list command - check output.""" self.run_command("bareasc", "with accents") assert "Antonin Dvorak" in self.io.getoutput() def test_bareasc_format_output(self): """Bare-ASCII version of list -f command - check output.""" self.run_command("bareasc", "with accents", "-f", "$artist:: $title") assert "Antonin Dvorak:: with accents\n" == self.io.getoutput() ================================================ FILE: test/plugins/test_beatport.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'beatport' plugin.""" import unittest from datetime import timedelta from beets.test import _common from beets.test.helper import BeetsTestCase from beetsplug import beatport class BeatportTest(BeetsTestCase): def _make_release_response(self): """Returns a dict that mimics a response from the beatport API. The results were retrieved from: https://oauth-api.beatport.com/catalog/3/releases?id=1742984 The list of elements on the returned dict is incomplete, including just those required for the tests on this class. """ results = { "id": 1742984, "type": "release", "name": "Charade", "slug": "charade", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "audioFormat": "", "category": "Release", "currentStatus": "General Content", "catalogNumber": "GR089", "description": "", "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ {"id": 9, "name": "Breaks", "slug": "breaks", "type": "genre"} ], } return results def _make_tracks_response(self): """Return a list that mimics a response from the beatport API. The results were retrieved from: https://oauth-api.beatport.com/catalog/3/tracks?releaseId=1742984 The list of elements on the returned list is incomplete, including just those required for the tests on this class. """ results = [ { "id": 7817567, "type": "track", "sku": "track-7817567", "name": "Mirage a Trois", "trackNumber": 1, "mixName": "Original Mix", "title": "Mirage a Trois (Original Mix)", "slug": "mirage-a-trois-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:05", "lengthMs": 425421, "bpm": 90, "key": { "standard": { "letter": "G", "sharp": False, "flat": False, "chord": "minor", }, "shortName": "Gmin", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, { "id": 7817568, "type": "track", "sku": "track-7817568", "name": "Aeon Bahamut", "trackNumber": 2, "mixName": "Original Mix", "title": "Aeon Bahamut (Original Mix)", "slug": "aeon-bahamut-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:38", "lengthMs": 458000, "bpm": 100, "key": { "standard": { "letter": "G", "sharp": False, "flat": False, "chord": "major", }, "shortName": "Gmaj", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, { "id": 7817569, "type": "track", "sku": "track-7817569", "name": "Trancendental Medication", "trackNumber": 3, "mixName": "Original Mix", "title": "Trancendental Medication (Original Mix)", "slug": "trancendental-medication-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "1:08", "lengthMs": 68571, "bpm": 141, "key": { "standard": { "letter": "F", "sharp": False, "flat": False, "chord": "major", }, "shortName": "Fmaj", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, { "id": 7817570, "type": "track", "sku": "track-7817570", "name": "A List of Instructions for When I'm Human", "trackNumber": 4, "mixName": "Original Mix", "title": "A List of Instructions for When I'm Human (Original Mix)", "slug": "a-list-of-instructions-for-when-im-human-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "6:57", "lengthMs": 417913, "bpm": 88, "key": { "standard": { "letter": "A", "sharp": False, "flat": False, "chord": "minor", }, "shortName": "Amin", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, { "id": 7817571, "type": "track", "sku": "track-7817571", "name": "The Great Shenanigan", "trackNumber": 5, "mixName": "Original Mix", "title": "The Great Shenanigan (Original Mix)", "slug": "the-great-shenanigan-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "9:49", "lengthMs": 589875, "bpm": 123, "key": { "standard": { "letter": "E", "sharp": False, "flat": True, "chord": "major", }, "shortName": "E♭maj", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, { "id": 7817572, "type": "track", "sku": "track-7817572", "name": "Charade", "trackNumber": 6, "mixName": "Original Mix", "title": "Charade (Original Mix)", "slug": "charade-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:05", "lengthMs": 425423, "bpm": 123, "key": { "standard": { "letter": "A", "sharp": False, "flat": False, "chord": "major", }, "shortName": "Amaj", }, "artists": [ { "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist", } ], "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade", }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True, }, }, ] return results def setUp(self): super().setUp() # Set up 'album'. response_release = self._make_release_response() self.album = beatport.BeatportRelease(response_release) # Set up 'tracks'. response_tracks = self._make_tracks_response() self.tracks = [beatport.BeatportTrack(t) for t in response_tracks] # Set up 'test_album'. self.test_album = self.mk_test_album() # Set up 'test_tracks' self.test_tracks = self.test_album.items() def mk_test_album(self): items = [_common.item() for _ in range(6)] for item in items: item.album = "Charade" item.catalognum = "GR089" item.label = "Gravitas Recordings" item.artist = "Supersillyus" item.year = 2016 item.comp = False item.label_name = "Gravitas Recordings" item.genres = ["Glitch Hop", "Breaks"] item.year = 2016 item.month = 4 item.day = 11 item.mix_name = "Original Mix" items[0].title = "Mirage a Trois" items[1].title = "Aeon Bahamut" items[2].title = "Trancendental Medication" items[3].title = "A List of Instructions for When I'm Human" items[4].title = "The Great Shenanigan" items[5].title = "Charade" items[0].length = timedelta(minutes=7, seconds=5).total_seconds() items[1].length = timedelta(minutes=7, seconds=38).total_seconds() items[2].length = timedelta(minutes=1, seconds=8).total_seconds() items[3].length = timedelta(minutes=6, seconds=57).total_seconds() items[4].length = timedelta(minutes=9, seconds=49).total_seconds() items[5].length = timedelta(minutes=7, seconds=5).total_seconds() items[0].url = "mirage-a-trois-original-mix" items[1].url = "aeon-bahamut-original-mix" items[2].url = "trancendental-medication-original-mix" items[3].url = "a-list-of-instructions-for-when-im-human-original-mix" items[4].url = "the-great-shenanigan-original-mix" items[5].url = "charade-original-mix" counter = 0 for item in items: counter += 1 item.track_number = counter items[0].bpm = 90 items[1].bpm = 100 items[2].bpm = 141 items[3].bpm = 88 items[4].bpm = 123 items[5].bpm = 123 items[0].initial_key = "Gmin" items[1].initial_key = "Gmaj" items[2].initial_key = "Fmaj" items[3].initial_key = "Amin" items[4].initial_key = "E♭maj" items[5].initial_key = "Amaj" for item in items: self.lib.add(item) album = self.lib.add_album(items) album.store() return album # Test BeatportRelease. def test_album_name_applied(self): assert self.album.name == self.test_album["album"] def test_catalog_number_applied(self): assert self.album.catalog_number == self.test_album["catalognum"] def test_label_applied(self): assert self.album.label_name == self.test_album["label"] def test_category_applied(self): assert self.album.category == "Release" def test_album_url_applied(self): assert self.album.url == "https://beatport.com/release/charade/1742984" # Test BeatportTrack. def test_title_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert track.name == test_track.title def test_mix_name_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert track.mix_name == test_track.mix_name def test_length_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert int(track.length.total_seconds()) == int(test_track.length) def test_track_url_applied(self): # Specify beatport ids here because an 'item.id' is beets-internal. ids = [ 7817567, 7817568, 7817569, 7817570, 7817571, 7817572, ] # Concatenate with 'id' to pass strict equality test. for track, test_track, id in zip(self.tracks, self.test_tracks, ids): assert ( track.url == f"https://beatport.com/track/{test_track.url}/{id}" ) def test_bpm_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert track.bpm == test_track.bpm def test_initial_key_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert track.initial_key == test_track.initial_key def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): assert track.genres == test_track.genres class BeatportResponseEmptyTest(unittest.TestCase): def _make_tracks_response(self): results = [ { "id": 7817567, "name": "Mirage a Trois", "genres": [ { "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre", } ], "subGenres": [ { "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre", } ], } ] return results def setUp(self): super().setUp() # Set up 'tracks'. self.response_tracks = self._make_tracks_response() self.tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] # Make alias to be congruent with class `BeatportTest`. self.test_tracks = self.response_tracks def test_response_tracks_empty(self): response_tracks = [] tracks = [beatport.BeatportTrack(t) for t in response_tracks] assert tracks == [] def test_sub_genre_empty_fallback(self): """No 'sub_genre' is provided. Test if fallback to 'genre' works.""" self.response_tracks[0]["subGenres"] = [] tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] self.test_tracks[0]["subGenres"] = [] assert tracks[0].genres == [self.test_tracks[0]["genres"][0]["name"]] def test_genre_empty(self): """No 'genre' is provided. Test if 'sub_genre' is applied.""" self.response_tracks[0]["genres"] = [] tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] self.test_tracks[0]["genres"] = [] assert tracks[0].genres == [self.test_tracks[0]["subGenres"][0]["name"]] ================================================ FILE: test/plugins/test_bpd.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for BPD's implementation of the MPD protocol.""" import multiprocessing as mp import os import socket import tempfile import threading import time import unittest from contextlib import contextmanager from typing import ClassVar from unittest.mock import MagicMock, patch import confuse import pytest import yaml from beets.test.helper import PluginTestCase from beets.util import bluelet bpd = pytest.importorskip("beetsplug.bpd", exc_type=ImportError) class CommandParseTest(unittest.TestCase): def test_no_args(self): s = r"command" c = bpd.Command(s) assert c.name == "command" assert c.args == [] def test_one_unquoted_arg(self): s = r"command hello" c = bpd.Command(s) assert c.name == "command" assert c.args == ["hello"] def test_two_unquoted_args(self): s = r"command hello there" c = bpd.Command(s) assert c.name == "command" assert c.args == ["hello", "there"] def test_one_quoted_arg(self): s = r'command "hello there"' c = bpd.Command(s) assert c.name == "command" assert c.args == ["hello there"] def test_heterogenous_args(self): s = r'command "hello there" sir' c = bpd.Command(s) assert c.name == "command" assert c.args == ["hello there", "sir"] def test_quote_in_arg(self): s = r'command "hello \" there"' c = bpd.Command(s) assert c.args == ['hello " there'] def test_backslash_in_arg(self): s = r'command "hello \\ there"' c = bpd.Command(s) assert c.args == ["hello \\ there"] class MPCResponse: def __init__(self, raw_response): body = b"\n".join(raw_response.split(b"\n")[:-2]).decode("utf-8") self.data = self._parse_body(body) status = raw_response.split(b"\n")[-2].decode("utf-8") self.ok, self.err_data = self._parse_status(status) def _parse_status(self, status): """Parses the first response line, which contains the status.""" if status.startswith("OK") or status.startswith("list_OK"): return True, None elif status.startswith("ACK"): code, rest = status[5:].split("@", 1) pos, rest = rest.split("]", 1) cmd, rest = rest[2:].split("}") return False, (int(code), int(pos), cmd, rest[1:]) else: raise RuntimeError(f"Unexpected status: {status!r}") def _parse_body(self, body): """Messages are generally in the format "header: content". Convert them into a dict, storing the values for repeated headers as lists of strings, and non-repeated ones as string. """ data = {} repeated_headers = set() for line in body.split("\n"): if not line: continue if ":" not in line: raise RuntimeError(f"Unexpected line: {line!r}") header, content = line.split(":", 1) content = content.lstrip() if header in repeated_headers: data[header].append(content) elif header in data: data[header] = [data[header], content] repeated_headers.add(header) else: data[header] = content return data class MPCClient: def __init__(self, sock, do_hello=True): self.sock = sock self.buf = b"" if do_hello: hello = self.get_response() if not hello.ok: raise RuntimeError("Bad hello") def get_response(self, force_multi=None): """Wait for a full server response and wrap it in a helper class. If the request was a batch request then this will return a list of `MPCResponse`s, one for each processed subcommand. """ response = b"" responses = [] while True: line = self.readline() response += line if line.startswith(b"OK") or line.startswith(b"ACK"): if force_multi or any(responses): if line.startswith(b"ACK"): responses.append(MPCResponse(response)) n_remaining = force_multi - len(responses) responses.extend([None] * n_remaining) return responses else: return MPCResponse(response) if line.startswith(b"list_OK"): responses.append(MPCResponse(response)) response = b"" elif not line: raise RuntimeError(f"Unexpected response: {line!r}") def serialise_command(self, command, *args): cmd = [command.encode("utf-8")] for arg in [a.encode("utf-8") for a in args]: if b" " in arg: cmd.append(b'"' + arg + b'"') else: cmd.append(arg) return b" ".join(cmd) + b"\n" def send_command(self, command, *args): request = self.serialise_command(command, *args) self.sock.sendall(request) return self.get_response() def send_commands(self, *commands): """Use MPD command batching to send multiple commands at once. Each item of commands is a tuple containing a command followed by any arguments. """ requests = [] for command_and_args in commands: command = command_and_args[0] args = command_and_args[1:] requests.append(self.serialise_command(command, *args)) requests.insert(0, b"command_list_ok_begin\n") requests.append(b"command_list_end\n") request = b"".join(requests) self.sock.sendall(request) return self.get_response(force_multi=len(commands)) def readline(self, terminator=b"\n", bufsize=1024): """Reads a line of data from the socket.""" while True: if terminator in self.buf: line, self.buf = self.buf.split(terminator, 1) line += terminator return line self.sock.settimeout(1) data = self.sock.recv(bufsize) if data: self.buf += data else: line = self.buf self.buf = b"" return line def implements(commands, fail=False): def _test(self): with self.run_bpd() as client: response = client.send_command("commands") self._assert_ok(response) implemented = response.data["command"] assert commands.intersection(implemented) == commands return unittest.expectedFailure(_test) if fail else _test bluelet_listener = bluelet.Listener @patch("beets.util.bluelet.Listener") def start_server(args, assigned_port, listener_patch): """Start the bpd server, writing the port to `assigned_port`.""" def listener_wrap(host, port): """Wrap `bluelet.Listener`, writing the port to `assigend_port`.""" # `bluelet.Listener` has previously been saved to # `bluelet_listener` as this function will replace it at its # original location. listener = bluelet_listener(host, port) # read port assigned by OS assigned_port.put_nowait(listener.sock.getsockname()[1]) return listener listener_patch.side_effect = listener_wrap import beets.ui beets.ui.main(args) class BPDTestHelper(PluginTestCase): db_on_disk = True plugin = "bpd" def setUp(self): super().setUp() self.item1 = self.add_item( title="Track One Title", track=1, album="Album Title", artist="Artist Name", ) self.item2 = self.add_item( title="Track Two Title", track=2, album="Album Title", artist="Artist Name", ) self.lib.add_album([self.item1, self.item2]) @contextmanager def run_bpd( self, host="localhost", password=None, do_hello=True, second_client=False, ): """Runs BPD in another process, configured with the same library database as we created in the setUp method. Exposes a client that is connected to the server, and kills the server at the end. """ # Create a config file: config = { "pluginpath": [str(self.temp_dir_path)], "plugins": "bpd", # use port 0 to let the OS choose a free port "bpd": {"host": host, "port": 0, "control_port": 0}, } if password: config["bpd"]["password"] = password config_file = tempfile.NamedTemporaryFile( mode="wb", dir=str(self.temp_dir_path), suffix=".yaml", delete=False, ) config_file.write( yaml.dump(config, Dumper=confuse.Dumper, encoding="utf-8") ) config_file.close() # Fork and launch BPD in the new process: assigned_port = mp.Queue(2) # 2 slots, `control_port` and `port` server = mp.Process( target=start_server, args=( [ "--library", self.config["library"].as_filename(), "--directory", os.fsdecode(self.libdir), "--config", os.fsdecode(config_file.name), "bpd", ], assigned_port, ), ) server.start() try: assigned_port.get(timeout=1) # skip control_port port = assigned_port.get(timeout=0.5) # read port sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.connect((host, port)) if second_client: sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock2.connect((host, port)) yield ( MPCClient(sock, do_hello), MPCClient(sock2, do_hello), ) finally: sock2.close() else: yield MPCClient(sock, do_hello) finally: sock.close() finally: server.terminate() server.join(timeout=0.2) def _assert_ok(self, *responses): for response in responses: assert response is not None assert response.ok, f"Response failed: {response.err_data}" def _assert_failed(self, response, code, pos=None): """Check that a command failed with a specific error code. If this is a list of responses, first check all preceding commands were OK. """ if pos is not None: previous_commands = response[0:pos] self._assert_ok(*previous_commands) response = response[pos] assert not response.ok if pos is not None: assert pos == response.err_data[1] if code is not None: assert code == response.err_data[0] def _bpd_add(self, client, *items, **kwargs): """Add the given item to the BPD playlist or queue.""" paths = [ "/".join( [ item.artist, item.album, os.fsdecode(os.path.basename(item.path)), ] ) for item in items ] playlist = kwargs.get("playlist") if playlist: commands = [("playlistadd", playlist, path) for path in paths] else: commands = [("add", path) for path in paths] responses = client.send_commands(*commands) self._assert_ok(*responses) class BPDTest(BPDTestHelper): def test_server_hello(self): with self.run_bpd(do_hello=False) as client: assert client.readline() == b"OK MPD 0.16.0\n" def test_unknown_cmd(self): with self.run_bpd() as client: response = client.send_command("notacommand") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_unexpected_argument(self): with self.run_bpd() as client: response = client.send_command("ping", "extra argument") self._assert_failed(response, bpd.ERROR_ARG) def test_missing_argument(self): with self.run_bpd() as client: response = client.send_command("add") self._assert_failed(response, bpd.ERROR_ARG) def test_system_error(self): with self.run_bpd() as client: response = client.send_command("crash") self._assert_failed(response, bpd.ERROR_SYSTEM) def test_empty_request(self): with self.run_bpd() as client: response = client.send_command("") self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDQueryTest(BPDTestHelper): test_implements_query = implements( { "clearerror", } ) def test_cmd_currentsong(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ("play",), ("currentsong",), ("stop",), ("currentsong",) ) self._assert_ok(*responses) assert "1" == responses[1].data["Id"] assert "Id" not in responses[3].data def test_cmd_currentsong_tagtypes(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands(("play",), ("currentsong",)) self._assert_ok(*responses) assert BPDConnectionTest.TAGTYPES.union(BPDQueueTest.METADATA) == set( responses[1].data.keys() ) def test_cmd_status(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("status",), ("play",), ("status",) ) self._assert_ok(*responses) fields_not_playing = { "repeat", "random", "single", "consume", "playlist", "playlistlength", "mixrampdb", "state", "volume", } assert fields_not_playing == set(responses[0].data.keys()) fields_playing = fields_not_playing | { "song", "songid", "time", "elapsed", "bitrate", "duration", "audio", "nextsong", "nextsongid", } assert fields_playing == set(responses[2].data.keys()) def test_cmd_stats(self): with self.run_bpd() as client: response = client.send_command("stats") self._assert_ok(response) details = { "artists", "albums", "songs", "uptime", "db_playtime", "db_update", "playtime", } assert details == set(response.data.keys()) def test_cmd_idle(self): def _toggle(c): for _ in range(3): rs = c.send_commands(("play",), ("pause",)) # time.sleep(0.05) # uncomment if test is flaky if any(not r.ok for r in rs): raise RuntimeError("Toggler failed") with self.run_bpd(second_client=True) as (client, client2): self._bpd_add(client, self.item1, self.item2) toggler = threading.Thread(target=_toggle, args=(client2,)) toggler.start() # Idling will hang until the toggler thread changes the play state. # Since the client sockets have a 1s timeout set at worst this will # raise a socket.timeout and fail the test if the toggler thread # manages to finish before the idle command is sent here. response = client.send_command("idle", "player") toggler.join() self._assert_ok(response) def test_cmd_idle_with_pending(self): with self.run_bpd(second_client=True) as (client, client2): response1 = client.send_command("random", "1") response2 = client2.send_command("idle") self._assert_ok(response1, response2) assert "options" == response2.data["changed"] def test_cmd_noidle(self): with self.run_bpd() as client: # Manually send a command without reading a response. request = client.serialise_command("idle") client.sock.sendall(request) time.sleep(0.01) response = client.send_command("noidle") self._assert_ok(response) def test_cmd_noidle_when_not_idle(self): with self.run_bpd() as client: # Manually send a command without reading a response. request = client.serialise_command("noidle") client.sock.sendall(request) response = client.send_command("notacommand") self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDPlaybackTest(BPDTestHelper): test_implements_playback = implements( { "random", } ) def test_cmd_consume(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("consume", "0"), ("playlistinfo",), ("next",), ("playlistinfo",), ("consume", "1"), ("playlistinfo",), ("play", "0"), ("next",), ("playlistinfo",), ("status",), ) self._assert_ok(*responses) assert responses[1].data["Id"] == responses[3].data["Id"] assert ["1", "2"] == responses[5].data["Id"] assert "2" == responses[8].data["Id"] assert "1" == responses[9].data["consume"] assert "play" == responses[9].data["state"] def test_cmd_consume_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("consume", "1"), ("play", "1"), ("playlistinfo",), ("previous",), ("playlistinfo",), ("status",), ) self._assert_ok(*responses) assert ["1", "2"] == responses[2].data["Id"] assert "1" == responses[4].data["Id"] assert "play" == responses[5].data["state"] def test_cmd_single(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("status",), ("single", "1"), ("play",), ("status",), ("next",), ("status",), ) self._assert_ok(*responses) assert "0" == responses[0].data["single"] assert "1" == responses[3].data["single"] assert "play" == responses[3].data["state"] assert "stop" == responses[5].data["state"] def test_cmd_repeat(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("repeat", "1"), ("play",), ("currentsong",), ("next",), ("currentsong",), ("next",), ("currentsong",), ) self._assert_ok(*responses) assert "1" == responses[2].data["Id"] assert "2" == responses[4].data["Id"] assert "1" == responses[6].data["Id"] def test_cmd_repeat_with_single(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("repeat", "1"), ("single", "1"), ("play",), ("currentsong",), ("next",), ("status",), ("currentsong",), ) self._assert_ok(*responses) assert "1" == responses[3].data["Id"] assert "play" == responses[5].data["state"] assert "1" == responses[6].data["Id"] def test_cmd_repeat_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("repeat", "1"), ("play",), ("currentsong",), ("previous",), ("currentsong",), ) self._assert_ok(*responses) assert "1" == responses[2].data["Id"] assert "2" == responses[4].data["Id"] def test_cmd_repeat_with_single_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("repeat", "1"), ("single", "1"), ("play",), ("currentsong",), ("previous",), ("status",), ("currentsong",), ) self._assert_ok(*responses) assert "1" == responses[3].data["Id"] assert "play" == responses[5].data["state"] assert "1" == responses[6].data["Id"] def test_cmd_crossfade(self): with self.run_bpd() as client: responses = client.send_commands( ("status",), ("crossfade", "123"), ("status",), ("crossfade", "-2"), ) response = client.send_command("crossfade", "0.5") self._assert_failed(responses, bpd.ERROR_ARG, pos=3) self._assert_failed(response, bpd.ERROR_ARG) assert "xfade" not in responses[0].data assert 123 == pytest.approx(int(responses[2].data["xfade"])) def test_cmd_mixrampdb(self): with self.run_bpd() as client: responses = client.send_commands(("mixrampdb", "-17"), ("status",)) self._assert_ok(*responses) assert -17 == pytest.approx(float(responses[1].data["mixrampdb"])) def test_cmd_mixrampdelay(self): with self.run_bpd() as client: responses = client.send_commands( ("mixrampdelay", "2"), ("status",), ("mixrampdelay", "nan"), ("status",), ("mixrampdelay", "-2"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=4) assert 2 == pytest.approx(float(responses[1].data["mixrampdelay"])) assert "mixrampdelay" not in responses[3].data def test_cmd_setvol(self): with self.run_bpd() as client: responses = client.send_commands( ("setvol", "67"), ("status",), ("setvol", "32"), ("status",), ("setvol", "101"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=4) assert "67" == responses[1].data["volume"] assert "32" == responses[3].data["volume"] def test_cmd_volume(self): with self.run_bpd() as client: responses = client.send_commands( ("setvol", "10"), ("volume", "5"), ("volume", "-2"), ("status",) ) self._assert_ok(*responses) assert "13" == responses[3].data["volume"] def test_cmd_replay_gain(self): with self.run_bpd() as client: responses = client.send_commands( ("replay_gain_mode", "track"), ("replay_gain_status",), ("replay_gain_mode", "notanoption"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) assert "track" == responses[1].data["replay_gain_mode"] class BPDControlTest(BPDTestHelper): test_implements_control = implements( { "seek", "seekid", "seekcur", }, fail=True, ) def test_cmd_play(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("status",), ("play",), ("status",), ("play", "1"), ("currentsong",), ) self._assert_ok(*responses) assert "stop" == responses[0].data["state"] assert "play" == responses[2].data["state"] assert "2" == responses[4].data["Id"] def test_cmd_playid(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("playid", "2"), ("currentsong",), ("clear",) ) self._bpd_add(client, self.item2, self.item1) responses.extend( client.send_commands(("playid", "2"), ("currentsong",)) ) self._assert_ok(*responses) assert "2" == responses[1].data["Id"] assert "2" == responses[4].data["Id"] def test_cmd_pause(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ("play",), ("pause",), ("status",), ("currentsong",) ) self._assert_ok(*responses) assert "pause" == responses[2].data["state"] assert "1" == responses[3].data["Id"] def test_cmd_stop(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ("play",), ("stop",), ("status",), ("currentsong",) ) self._assert_ok(*responses) assert "stop" == responses[2].data["state"] assert "Id" not in responses[3].data def test_cmd_next(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("play",), ("currentsong",), ("next",), ("currentsong",), ("next",), ("status",), ) self._assert_ok(*responses) assert "1" == responses[1].data["Id"] assert "2" == responses[3].data["Id"] assert "stop" == responses[5].data["state"] def test_cmd_previous(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("play", "1"), ("currentsong",), ("previous",), ("currentsong",), ("previous",), ("status",), ("currentsong",), ) self._assert_ok(*responses) assert "2" == responses[1].data["Id"] assert "1" == responses[3].data["Id"] assert "play" == responses[5].data["state"] assert "1" == responses[6].data["Id"] class BPDQueueTest(BPDTestHelper): test_implements_queue = implements( { "addid", "clear", "delete", "deleteid", "move", "moveid", "playlist", "playlistfind", "playlistsearch", "plchanges", "plchangesposid", "prio", "prioid", "rangeid", "shuffle", "swap", "swapid", "addtagid", "cleartagid", }, fail=True, ) METADATA: ClassVar[set[str]] = {"Pos", "Time", "Id", "file", "duration"} def test_cmd_add(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) def test_cmd_playlistinfo(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("playlistinfo",), ("playlistinfo", "0"), ("playlistinfo", "0:2"), ("playlistinfo", "200"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=3) assert "1" == responses[1].data["Id"] assert ["1", "2"] == responses[2].data["Id"] def test_cmd_playlistinfo_tagtypes(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) response = client.send_command("playlistinfo", "0") self._assert_ok(response) assert BPDConnectionTest.TAGTYPES.union(BPDQueueTest.METADATA) == set( response.data.keys() ) def test_cmd_playlistid(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ("playlistid", "2"), ("playlistid",) ) self._assert_ok(*responses) assert "Track Two Title" == responses[0].data["Title"] assert ["1", "2"] == responses[1].data["Track"] class BPDPlaylistsTest(BPDTestHelper): test_implements_playlists = implements({"playlistadd"}) def test_cmd_listplaylist(self): with self.run_bpd() as client: response = client.send_command("listplaylist", "anything") self._assert_failed(response, bpd.ERROR_NO_EXIST) def test_cmd_listplaylistinfo(self): with self.run_bpd() as client: response = client.send_command("listplaylistinfo", "anything") self._assert_failed(response, bpd.ERROR_NO_EXIST) def test_cmd_listplaylists(self): with self.run_bpd() as client: response = client.send_command("listplaylists") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_load(self): with self.run_bpd() as client: response = client.send_command("load", "anything") self._assert_failed(response, bpd.ERROR_NO_EXIST) @unittest.expectedFailure def test_cmd_playlistadd(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, playlist="anything") def test_cmd_playlistclear(self): with self.run_bpd() as client: response = client.send_command("playlistclear", "anything") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_playlistdelete(self): with self.run_bpd() as client: response = client.send_command("playlistdelete", "anything", "0") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_playlistmove(self): with self.run_bpd() as client: response = client.send_command("playlistmove", "anything", "0", "1") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_rename(self): with self.run_bpd() as client: response = client.send_command("rename", "anything", "newname") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_rm(self): with self.run_bpd() as client: response = client.send_command("rm", "anything") self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_save(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) response = client.send_command("save", "newplaylist") self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDDatabaseTest(BPDTestHelper): test_implements_database = implements( { "albumart", "find", "findadd", "listall", "listallinfo", "listfiles", "readcomments", "searchadd", "searchaddpl", "update", "rescan", }, fail=True, ) def test_cmd_search(self): with self.run_bpd() as client: response = client.send_command("search", "track", "1") self._assert_ok(response) assert self.item1.title == response.data["Title"] def test_cmd_list(self): with self.run_bpd() as client: responses = client.send_commands( ("list", "album"), ("list", "track"), ("list", "album", "artist", "Artist Name", "track"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) assert "Album Title" == responses[0].data["Album"] assert ["1", "2"] == responses[1].data["Track"] def test_cmd_list_three_arg_form(self): with self.run_bpd() as client: responses = client.send_commands( ("list", "album", "artist", "Artist Name"), ("list", "album", "Artist Name"), ("list", "track", "Artist Name"), ) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) assert responses[0].data == responses[1].data def test_cmd_lsinfo(self): with self.run_bpd() as client: response1 = client.send_command("lsinfo") self._assert_ok(response1) response2 = client.send_command( "lsinfo", response1.data["directory"] ) self._assert_ok(response2) response3 = client.send_command( "lsinfo", response2.data["directory"] ) self._assert_ok(response3) assert self.item1.title in response3.data["Title"] def test_cmd_count(self): with self.run_bpd() as client: response = client.send_command("count", "track", "1") self._assert_ok(response) assert "1" == response.data["songs"] assert "0" == response.data["playtime"] class BPDMountsTest(BPDTestHelper): test_implements_mounts = implements( { "mount", "unmount", "listmounts", "listneighbors", }, fail=True, ) class BPDStickerTest(BPDTestHelper): test_implements_stickers = implements( { "sticker", }, fail=True, ) class BPDConnectionTest(BPDTestHelper): test_implements_connection = implements( { "close", "kill", } ) ALL_MPD_TAGTYPES: ClassVar[set[str]] = { "Artist", "ArtistSort", "Album", "AlbumSort", "AlbumArtist", "AlbumArtistSort", "Title", "Track", "Name", "Genre", "Date", "Composer", "Performer", "Comment", "Disc", "Label", "OriginalDate", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ_TRACKID", "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ_WORKID", } UNSUPPORTED_TAGTYPES: ClassVar[set[str]] = { "MUSICBRAINZ_WORKID", # not tracked by beets "Performer", # not tracked by beets "AlbumSort", # not tracked by beets "Name", # junk field for internet radio } TAGTYPES = ALL_MPD_TAGTYPES.difference(UNSUPPORTED_TAGTYPES) def test_cmd_password(self): with self.run_bpd(password="abc123") as client: response = client.send_command("status") self._assert_failed(response, bpd.ERROR_PERMISSION) response = client.send_command("password", "wrong") self._assert_failed(response, bpd.ERROR_PASSWORD) responses = client.send_commands( ("password", "abc123"), ("status",) ) self._assert_ok(*responses) def test_cmd_ping(self): with self.run_bpd() as client: response = client.send_command("ping") self._assert_ok(response) def test_cmd_tagtypes(self): with self.run_bpd() as client: response = client.send_command("tagtypes") self._assert_ok(response) assert self.TAGTYPES == set(response.data["tagtype"]) @unittest.expectedFailure def test_tagtypes_mask(self): with self.run_bpd() as client: response = client.send_command("tagtypes", "clear") self._assert_ok(response) class BPDPartitionTest(BPDTestHelper): test_implements_partitions = implements( { "partition", "listpartitions", "newpartition", }, fail=True, ) class BPDDeviceTest(BPDTestHelper): test_implements_devices = implements( { "disableoutput", "enableoutput", "toggleoutput", "outputs", }, fail=True, ) class BPDReflectionTest(BPDTestHelper): test_implements_reflection = implements( { "config", "commands", "notcommands", "urlhandlers", }, fail=True, ) @patch( "beetsplug.bpd.gstplayer.GstPlayer.get_decoders", MagicMock(return_value={"default": ({"audio/mpeg"}, {"mp3"})}), ) def test_cmd_decoders(self): with self.run_bpd() as client: response = client.send_command("decoders") self._assert_ok(response) assert "default" == response.data["plugin"] assert "mp3" == response.data["suffix"] assert "audio/mpeg" == response.data["mime_type"] class BPDPeersTest(BPDTestHelper): test_implements_peers = implements( { "subscribe", "unsubscribe", "channels", "readmessages", "sendmessage", }, fail=True, ) ================================================ FILE: test/plugins/test_bucket.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'bucket' plugin.""" from datetime import datetime import pytest from beets import config, ui from beets.test.helper import BeetsTestCase from beetsplug import bucket class BucketPluginTest(BeetsTestCase): def setUp(self): super().setUp() self.plugin = bucket.BucketPlugin() def _setup_config( self, bucket_year=[], bucket_alpha=[], bucket_alpha_regex={}, extrapolate=False, ): config["bucket"]["bucket_year"] = bucket_year config["bucket"]["bucket_alpha"] = bucket_alpha config["bucket"]["bucket_alpha_regex"] = bucket_alpha_regex config["bucket"]["extrapolate"] = extrapolate self.plugin.setup() def test_year_single_year(self): """If a single year is given, range starts from this year and stops at the year preceding the one of next bucket.""" self._setup_config(bucket_year=["1950s", "1970s"]) assert self.plugin._tmpl_bucket("1959") == "1950s" assert self.plugin._tmpl_bucket("1969") == "1950s" def test_year_single_year_last_folder(self): """If a single year is given for the last bucket, extend it to current year.""" self._setup_config(bucket_year=["1950", "1970"]) assert self.plugin._tmpl_bucket("2014") == "1970" next_year = datetime.now().year + 1 assert self.plugin._tmpl_bucket(str(next_year)) == str(next_year) def test_year_two_years(self): """Buckets can be named with the 'from-to' syntax.""" self._setup_config(bucket_year=["1950-59", "1960-1969"]) assert self.plugin._tmpl_bucket("1959") == "1950-59" assert self.plugin._tmpl_bucket("1969") == "1960-1969" def test_year_multiple_years(self): """Buckets can be named by listing all the years""" self._setup_config(bucket_year=["1950,51,52,53"]) assert self.plugin._tmpl_bucket("1953") == "1950,51,52,53" assert self.plugin._tmpl_bucket("1974") == "1974" def test_year_out_of_range(self): """If no range match, return the year""" self._setup_config(bucket_year=["1950-59", "1960-69"]) assert self.plugin._tmpl_bucket("1974") == "1974" self._setup_config(bucket_year=[]) assert self.plugin._tmpl_bucket("1974") == "1974" def test_year_out_of_range_extrapolate(self): """If no defined range match, extrapolate all ranges using the most common syntax amongst existing buckets and return the matching one.""" self._setup_config(bucket_year=["1950-59", "1960-69"], extrapolate=True) assert self.plugin._tmpl_bucket("1914") == "1910-19" # pick single year format self._setup_config( bucket_year=["1962-81", "2002", "2012"], extrapolate=True ) assert self.plugin._tmpl_bucket("1983") == "1982" # pick from-end format self._setup_config( bucket_year=["1962-81", "2002", "2012-14"], extrapolate=True ) assert self.plugin._tmpl_bucket("1983") == "1982-01" # extrapolate add ranges, but never modifies existing ones self._setup_config( bucket_year=["1932", "1942", "1952", "1962-81", "2002"], extrapolate=True, ) assert self.plugin._tmpl_bucket("1975") == "1962-81" def test_alpha_all_chars(self): """Alphabet buckets can be named by listing all their chars""" self._setup_config(bucket_alpha=["ABCD", "FGH", "IJKL"]) assert self.plugin._tmpl_bucket("garry") == "FGH" def test_alpha_first_last_chars(self): """Alphabet buckets can be named by listing the 'from-to' syntax""" self._setup_config(bucket_alpha=["0->9", "A->D", "F-H", "I->Z"]) assert self.plugin._tmpl_bucket("garry") == "F-H" assert self.plugin._tmpl_bucket("2pac") == "0->9" def test_alpha_out_of_range(self): """If no range match, return the initial""" self._setup_config(bucket_alpha=["ABCD", "FGH", "IJKL"]) assert self.plugin._tmpl_bucket("errol") == "E" self._setup_config(bucket_alpha=[]) assert self.plugin._tmpl_bucket("errol") == "E" def test_alpha_regex(self): """Check regex is used""" self._setup_config( bucket_alpha=["foo", "bar"], bucket_alpha_regex={"foo": "^[a-d]", "bar": "^[e-z]"}, ) assert self.plugin._tmpl_bucket("alpha") == "foo" assert self.plugin._tmpl_bucket("delta") == "foo" assert self.plugin._tmpl_bucket("zeta") == "bar" assert self.plugin._tmpl_bucket("Alpha") == "A" def test_alpha_regex_mix(self): """Check mixing regex and non-regex is possible""" self._setup_config( bucket_alpha=["A - D", "E - L"], bucket_alpha_regex={"A - D": "^[0-9a-dA-D…äÄ]"}, ) assert self.plugin._tmpl_bucket("alpha") == "A - D" assert self.plugin._tmpl_bucket("Ärzte") == "A - D" assert self.plugin._tmpl_bucket("112") == "A - D" assert self.plugin._tmpl_bucket("…and Oceans") == "A - D" assert self.plugin._tmpl_bucket("Eagles") == "E - L" def test_bad_alpha_range_def(self): """If bad alpha range definition, a UserError is raised.""" with pytest.raises(ui.UserError): self._setup_config(bucket_alpha=["$%"]) def test_bad_year_range_def_no4digits(self): """If bad year range definition, a UserError is raised. Range origin must be expressed on 4 digits. """ with pytest.raises(ui.UserError): self._setup_config(bucket_year=["62-64"]) def test_bad_year_range_def_nodigits(self): """If bad year range definition, a UserError is raised. At least the range origin must be declared. """ with pytest.raises(ui.UserError): self._setup_config(bucket_year=["nodigits"]) def check_span_from_str(self, sstr, dfrom, dto): d = bucket.span_from_str(sstr) assert dfrom == d["from"] assert dto == d["to"] def test_span_from_str(self): self.check_span_from_str("1980 2000", 1980, 2000) self.check_span_from_str("1980 00", 1980, 2000) self.check_span_from_str("1930 00", 1930, 2000) self.check_span_from_str("1930 50", 1930, 1950) ================================================ FILE: test/plugins/test_convert.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import fnmatch import os.path import re import sys import unittest from typing import TYPE_CHECKING import pytest from mediafile import MediaFile from beets import util from beets.library import Item from beets.test import _common from beets.test.helper import ( AsIsImporterMixin, ImportHelper, IOMixin, PluginTestCase, capture_log, ) from beetsplug import convert if TYPE_CHECKING: from pathlib import Path def shell_quote(text): import shlex return shlex.quote(text) class ConvertMixin: def tagged_copy_cmd(self, tag): """Return a conversion command that copies files and appends `tag` to the copy. """ if re.search("[^a-zA-Z0-9]", tag): raise ValueError( f"tag '{tag}' must only contain letters and digits" ) # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b"convert_stub.py").decode("utf-8") return f"{shell_quote(sys.executable)} {shell_quote(stub)} $source $dest {tag}" def file_endswith(self, path: Path, tag: str): """Check the path is a file and if its content ends with `tag`.""" assert path.exists() assert path.is_file() return path.read_bytes().endswith(tag.encode("utf-8")) class ConvertTestCase(IOMixin, ConvertMixin, PluginTestCase): db_on_disk = True plugin = "convert" @_common.slow_test() class ImportConvertTest(AsIsImporterMixin, ImportHelper, ConvertTestCase): def setUp(self): super().setUp() self.config["convert"] = { "dest": os.path.join(self.temp_dir, b"convert"), "command": self.tagged_copy_cmd("convert"), # Enforce running convert "max_bitrate": 1, "auto": True, "quiet": False, } def test_import_converted(self): self.run_asis_importer() item = self.lib.items().get() assert self.file_endswith(item.filepath, "convert") # FIXME: fails on windows @unittest.skipIf(sys.platform == "win32", "win32") def test_import_original_on_convert_error(self): # `false` exits with non-zero code self.config["convert"]["command"] = "false" self.run_asis_importer() item = self.lib.items().get() assert item is not None assert item.filepath.is_file() def test_delete_originals(self): self.config["convert"]["delete_originals"] = True self.run_asis_importer() for path in self.importer.paths: for root, dirnames, filenames in os.walk(path): assert len(fnmatch.filter(filenames, "*.mp3")) == 0, ( f"Non-empty import directory {util.displayable_path(path)}" ) def get_count_of_import_files(self): import_file_count = 0 for path in self.importer.paths: for root, _, filenames in os.walk(path): import_file_count += len(filenames) return import_file_count class ConvertCommand: """A mixin providing a utility method to run the `convert`command in tests. """ def run_convert_path(self, item, *args): """Run the `convert` command on a given path.""" return self.run_command("convert", *args, f"path:{item.filepath}") def run_convert(self, *args): """Run the `convert` command on `self.item`.""" return self.run_convert_path(self.item, *args) @_common.slow_test() class ConvertCliTest(ConvertTestCase, ConvertCommand): def setUp(self): super().setUp() self.album = self.add_album_fixture(ext="ogg") self.item = self.album.items()[0] self.convert_dest = self.temp_dir_path / "convert_dest" self.converted_mp3 = self.convert_dest / "converted.mp3" self.config["convert"] = { "dest": str(self.convert_dest), "paths": {"default": "converted"}, "format": "mp3", "formats": { "mp3": self.tagged_copy_cmd("mp3"), "ogg": self.tagged_copy_cmd("ogg"), "opus": { "command": self.tagged_copy_cmd("opus"), "extension": "ops", }, }, } def test_convert(self): self.io.addinput("y") self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_convert_with_auto_confirmation(self): self.run_convert("--yes") assert self.file_endswith(self.converted_mp3, "mp3") def test_reject_confirmation(self): self.io.addinput("n") self.run_convert() assert not self.converted_mp3.exists() def test_convert_keep_new(self): assert os.path.splitext(self.item.path)[1] == b".ogg" self.io.addinput("y") self.run_convert("--keep-new") self.item.load() assert os.path.splitext(self.item.path)[1] == b".mp3" def test_format_option(self): self.io.addinput("y") self.run_convert("--format", "opus") assert self.file_endswith(self.convert_dest / "converted.ops", "opus") def test_embed_album_art(self): self.config["convert"]["embed"] = True image_path = os.path.join(_common.RSRC, b"image-2x3.jpg") self.album.artpath = image_path self.album.store() with open(os.path.join(image_path), "rb") as f: image_data = f.read() self.io.addinput("y") self.run_convert() mediafile = MediaFile(self.converted_mp3) assert mediafile.images[0].data == image_data def test_skip_existing(self): converted = self.converted_mp3 self.touch(converted, content="XXX") self.run_convert("--yes") with open(converted) as f: assert f.read() == "XXX" def test_pretend(self): self.run_convert("--pretend") assert not self.converted_mp3.exists() def test_empty_query(self): with capture_log("beets.convert") as logs: self.run_convert("An impossible query") assert logs[0] == "convert: Empty query result." def test_no_transcode_when_maxbr_set_high_and_different_formats(self): self.config["convert"]["max_bitrate"] = 5000 self.io.addinput("y") self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_transcode_when_maxbr_set_low_and_different_formats(self): self.config["convert"]["max_bitrate"] = 5 self.io.addinput("y") self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_transcode_when_maxbr_set_to_none_and_different_formats(self): self.io.addinput("y") self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_no_transcode_when_maxbr_set_high_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5000 self.config["convert"]["format"] = "ogg" self.io.addinput("y") self.run_convert() assert not self.file_endswith( self.convert_dest / "converted.ogg", "ogg" ) def test_force_overrides_max_bitrate_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5000 self.config["convert"]["format"] = "ogg" self.io.addinput("y") self.run_convert("--force") converted = self.convert_dest / "converted.ogg" assert self.file_endswith(converted, "ogg") def test_transcode_when_maxbr_set_low_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5 self.config["convert"]["format"] = "ogg" self.io.addinput("y") self.run_convert() assert self.file_endswith(self.convert_dest / "converted.ogg", "ogg") def test_transcode_when_maxbr_set_to_none_and_same_formats(self): self.config["convert"]["format"] = "ogg" self.io.addinput("y") self.run_convert() assert not self.file_endswith( self.convert_dest / "converted.ogg", "ogg" ) def test_playlist(self): self.io.addinput("y") self.run_convert("--playlist", "playlist.m3u8") assert (self.convert_dest / "playlist.m3u8").exists() def test_playlist_pretend(self): self.run_convert("--playlist", "playlist.m3u8", "--pretend") assert not (self.convert_dest / "playlist.m3u8").exists() def test_force_overrides_no_convert(self): self.config["convert"]["formats"]["opus"] = { "command": self.tagged_copy_cmd("opus"), "extension": "ops", } self.config["convert"]["no_convert"] = "format:ogg" [item] = self.add_item_fixtures(ext="ogg") self.io.addinput("y") self.run_convert_path(item, "--format", "opus", "--force") converted = self.convert_dest / "converted.ops" assert self.file_endswith(converted, "opus") def assert_playlist_entry(self, expected_entry, *args): self.io.addinput("y") self.run_convert(*args, "--playlist", "playlist.m3u8") lines = (self.convert_dest / "playlist.m3u8").read_text().splitlines() assert lines[0] == "#EXTM3U" assert lines[1] == expected_entry def test_playlist_entry_uses_config_format(self): self.assert_playlist_entry("converted.mp3") def test_playlist_entry_uses_cli_format(self): self.assert_playlist_entry("converted.ops", "--format", "opus") def test_playlist_entry_keeps_original_extension_when_not_transcoded(self): self.config["convert"]["no_convert"] = "format:ogg" self.assert_playlist_entry("converted.ogg") def test_playlist_entry_keep_new_points_to_destination_file(self): self.assert_playlist_entry("converted.ogg", "--keep-new") @_common.slow_test() class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): """Test the effect of the `never_convert_lossy_files` option.""" def setUp(self): super().setUp() self.convert_dest = self.temp_dir_path / "convert_dest" self.config["convert"] = { "dest": str(self.convert_dest), "paths": {"default": "converted"}, "never_convert_lossy_files": True, "format": "mp3", "formats": { "mp3": self.tagged_copy_cmd("mp3"), }, } def test_transcode_from_lossless(self): [item] = self.add_item_fixtures(ext="flac") self.io.addinput("y") self.run_convert_path(item) converted = self.convert_dest / "converted.mp3" assert self.file_endswith(converted, "mp3") def test_transcode_from_lossy(self): self.config["convert"]["never_convert_lossy_files"] = False [item] = self.add_item_fixtures(ext="ogg") self.io.addinput("y") self.run_convert_path(item) converted = self.convert_dest / "converted.mp3" assert self.file_endswith(converted, "mp3") def test_transcode_from_lossy_prevented(self): [item] = self.add_item_fixtures(ext="ogg") self.io.addinput("y") self.run_convert_path(item) converted = self.convert_dest / "converted.ogg" assert not self.file_endswith(converted, "mp3") def test_force_overrides_never_convert_lossy_files(self): self.config["convert"]["formats"]["opus"] = { "command": self.tagged_copy_cmd("opus"), "extension": "ops", } [item] = self.add_item_fixtures(ext="ogg") self.io.addinput("y") self.run_convert_path(item, "--format", "opus", "--force") converted = self.convert_dest / "converted.ops" assert self.file_endswith(converted, "opus") class TestNoConvert: """Test the effect of the `no_convert` option.""" @pytest.mark.parametrize( "config_value, should_skip", [ ("", False), ("bitrate:320", False), ("bitrate:320 format:ogg", False), ("bitrate:320 , format:ogg", True), ], ) def test_no_convert_skip(self, config_value, should_skip): item = Item(format="ogg", bitrate=256) convert.config["convert"]["no_convert"] = config_value assert convert.in_no_convert(item) == should_skip ================================================ FILE: test/plugins/test_discogs.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for discogs plugin.""" from unittest.mock import Mock, patch import pytest from beets import config from beets.library import Item from beets.test._common import Bag from beets.test.helper import BeetsTestCase, capture_log from beetsplug.discogs import ArtistState, DiscogsPlugin def _artist(name: str, **kwargs): return { "id": 1, "name": name, "join": "", "role": "", "anv": "", "tracks": "", "resource_url": "", } | kwargs @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) class DGAlbumInfoTest(BeetsTestCase): def _make_release(self, tracks=None): """Returns a Bag that mimics a discogs_client.Release. The list of elements on the returned Bag is incomplete, including just those required for the tests on this class.""" data = { "id": "ALBUM ID", "uri": "https://www.discogs.com/release/release/13633721", "title": "ALBUM TITLE", "year": "3001", "artists": [_artist("ARTIST NAME", id="ARTIST ID", join=",")], "formats": [ { "descriptions": ["FORMAT DESC 1", "FORMAT DESC 2"], "name": "FORMAT", "qty": 1, } ], "styles": ["STYLE1", "STYLE2"], "genres": ["GENRE1", "GENRE2"], "labels": [ { "name": "LABEL NAME", "catno": "CATALOG NUMBER", } ], "tracklist": [], } if tracks: for recording in tracks: data["tracklist"].append(recording) return Bag( data=data, # Make some fields available as properties, as they are # accessed by DiscogsPlugin methods. title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) def _make_track(self, title, position="", duration="", type_=None): track = {"title": title, "position": position, "duration": duration} if type_ is not None: # Test samples on discogs_client do not have a 'type_' field, but # the API seems to return it. Values: 'track' for regular tracks, # 'heading' for descriptive texts (ie. not real tracks - 12.13.2). track["type_"] = type_ return track def _make_release_from_positions(self, positions): """Return a Bag that mimics a discogs_client.Release with a tracklist where tracks have the specified `positions`.""" tracks = [ self._make_track(f"TITLE{i}", position) for (i, position) in enumerate(positions, start=1) ] return self._make_release(tracks) def test_parse_media_for_tracks(self): tracks = [ self._make_track("TITLE ONE", "1", "01:01"), self._make_track("TITLE TWO", "2", "02:02"), ] release = self._make_release(tracks=tracks) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert d.media == "FORMAT" assert t[0].media == d.media assert t[1].media == d.media def test_parse_medium_numbers_single_medium(self): release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert d.mediums == 1 assert t[0].medium == 1 assert t[0].medium_total == 2 assert t[1].medium == 1 assert t[0].medium_total == 2 def test_parse_medium_numbers_two_mediums(self): release = self._make_release_from_positions(["1-1", "2-1"]) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert d.mediums == 2 assert t[0].medium == 1 assert t[0].medium_total == 1 assert t[1].medium == 2 assert t[1].medium_total == 1 def test_parse_medium_numbers_two_mediums_two_sided(self): release = self._make_release_from_positions(["A1", "B1", "C1"]) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert d.mediums == 2 assert t[0].medium == 1 assert t[0].medium_total == 2 assert t[0].medium_index == 1 assert t[1].medium == 1 assert t[1].medium_total == 2 assert t[1].medium_index == 2 assert t[2].medium == 2 assert t[2].medium_total == 1 assert t[2].medium_index == 1 def test_parse_track_indices(self): release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert t[0].medium_index == 1 assert t[0].index == 1 assert t[0].medium_total == 2 assert t[1].medium_index == 2 assert t[1].index == 2 assert t[1].medium_total == 2 def test_parse_track_indices_several_media(self): release = self._make_release_from_positions( ["1-1", "1-2", "2-1", "3-1"] ) d = DiscogsPlugin().get_album_info(release) t = d.tracks assert d.mediums == 3 assert t[0].medium_index == 1 assert t[0].index == 1 assert t[0].medium_total == 2 assert t[1].medium_index == 2 assert t[1].index == 2 assert t[1].medium_total == 2 assert t[2].medium_index == 1 assert t[2].index == 3 assert t[2].medium_total == 1 assert t[3].medium_index == 1 assert t[3].index == 4 assert t[3].medium_total == 1 def test_parse_tracklist_without_sides(self): """Test standard Discogs position 12.2.9#1: "without sides".""" release = self._make_release_from_positions(["1", "2", "3"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 def test_parse_tracklist_with_sides(self): """Test standard Discogs position 12.2.9#2: "with sides".""" release = self._make_release_from_positions(["A1", "A2", "B1", "B2"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 # 2 sides = 1 LP assert len(d.tracks) == 4 def test_parse_tracklist_multiple_lp(self): """Test standard Discogs position 12.2.9#3: "multiple LP".""" release = self._make_release_from_positions(["A1", "A2", "B1", "C1"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 2 # 3 sides = 1 LP + 1 LP assert len(d.tracks) == 4 def test_parse_tracklist_multiple_cd(self): """Test standard Discogs position 12.2.9#4: "multiple CDs".""" release = self._make_release_from_positions( ["1-1", "1-2", "2-1", "3-1"] ) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 3 assert len(d.tracks) == 4 def test_parse_tracklist_non_standard(self): """Test non standard Discogs position.""" release = self._make_release_from_positions(["I", "II", "III", "IV"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 4 def test_parse_tracklist_subtracks_dot(self): """Test standard Discogs position 12.2.9#5: "sub tracks, dots".""" release = self._make_release_from_positions(["1", "2.1", "2.2", "3"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 release = self._make_release_from_positions( ["A1", "A2.1", "A2.2", "A3"] ) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 def test_parse_tracklist_subtracks_letter(self): """Test standard Discogs position 12.2.9#5: "sub tracks, letter".""" release = self._make_release_from_positions(["A1", "A2a", "A2b", "A3"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 release = self._make_release_from_positions( ["A1", "A2.a", "A2.b", "A3"] ) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 def test_parse_tracklist_subtracks_extra_material(self): """Test standard Discogs position 12.2.9#6: "extra material".""" release = self._make_release_from_positions(["1", "2", "Video 1"]) d = DiscogsPlugin().get_album_info(release) assert d.mediums == 2 assert len(d.tracks) == 3 def test_parse_tracklist_subtracks_indices(self): """Test parsing of subtracks that include index tracks.""" release = self._make_release_from_positions(["", "", "1.1", "1.2"]) # Track 1: Index track with medium title release.data["tracklist"][0]["title"] = "MEDIUM TITLE" # Track 2: Index track with track group title release.data["tracklist"][1]["title"] = "TRACK GROUP TITLE" d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert d.tracks[0].disctitle == "MEDIUM TITLE" assert len(d.tracks) == 1 assert d.tracks[0].title == "TRACK GROUP TITLE" def test_parse_tracklist_subtracks_nested_logical(self): """Test parsing of subtracks defined inside a index track that are logical subtracks (ie. should be grouped together into a single track). """ release = self._make_release_from_positions(["1", "", "3"]) # Track 2: Index track with track group title, and sub_tracks release.data["tracklist"][1]["title"] = "TRACK GROUP TITLE" release.data["tracklist"][1]["sub_tracks"] = [ self._make_track("TITLE ONE", "2.1", "01:01"), self._make_track("TITLE TWO", "2.2", "02:02"), ] d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 3 assert d.tracks[1].title == "TRACK GROUP TITLE" def test_parse_tracklist_subtracks_nested_physical(self): """Test parsing of subtracks defined inside a index track that are physical subtracks (ie. should not be grouped together). """ release = self._make_release_from_positions(["1", "", "4"]) # Track 2: Index track with track group title, and sub_tracks release.data["tracklist"][1]["title"] = "TRACK GROUP TITLE" release.data["tracklist"][1]["sub_tracks"] = [ self._make_track("TITLE ONE", "2", "01:01"), self._make_track("TITLE TWO", "3", "02:02"), ] d = DiscogsPlugin().get_album_info(release) assert d.mediums == 1 assert len(d.tracks) == 4 assert d.tracks[1].title == "TITLE ONE" assert d.tracks[2].title == "TITLE TWO" def test_parse_tracklist_disctitles(self): """Test parsing of index tracks that act as disc titles.""" release = self._make_release_from_positions( ["", "1-1", "1-2", "", "2-1"] ) # Track 1: Index track with medium title (Cd1) release.data["tracklist"][0]["title"] = "MEDIUM TITLE CD1" # Track 4: Index track with medium title (Cd2) release.data["tracklist"][3]["title"] = "MEDIUM TITLE CD2" d = DiscogsPlugin().get_album_info(release) assert d.mediums == 2 assert d.tracks[0].disctitle == "MEDIUM TITLE CD1" assert d.tracks[1].disctitle == "MEDIUM TITLE CD1" assert d.tracks[2].disctitle == "MEDIUM TITLE CD2" assert len(d.tracks) == 3 def test_parse_minimal_release(self): """Test parsing of a release with the minimal amount of information.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [self._make_track("A", "1", "01:01")], "artists": [_artist("ARTIST NAME", id=321)], "title": "TITLE", } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME" assert d.album == "TITLE" assert len(d.tracks) == 1 def test_parse_release_without_required_fields(self): """Test parsing of a release that does not have the required fields.""" release = Bag(data={}, refresh=lambda *args: None) with capture_log() as logs: d = DiscogsPlugin().get_album_info(release) assert d is None assert "Release does not contain the required fields" in logs[0] def test_default_genre_style_settings(self): """Test genre default settings, genres to genre, styles to style""" release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) assert d.genres == ["GENRE1", "GENRE2"] assert d.style == "STYLE1, STYLE2" def test_append_style_to_genre(self): """Test appending style to genre if config enabled""" config["discogs"]["append_style_genre"] = True release = self._make_release_from_positions(["1", "2"]) d = DiscogsPlugin().get_album_info(release) assert d.genres == ["GENRE1", "GENRE2", "STYLE1", "STYLE2"] assert d.style == "STYLE1, STYLE2" def test_append_style_to_genre_no_style(self): """Test nothing appended to genre if style is empty""" config["discogs"]["append_style_genre"] = True release = self._make_release_from_positions(["1", "2"]) release.data["styles"] = [] d = DiscogsPlugin().get_album_info(release) assert d.genres == ["GENRE1", "GENRE2"] assert d.style is None def test_strip_disambiguation(self): """Test removing disambiguation from all disambiguated fields.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [ { "title": "track", "position": "A", "type_": "track", "duration": "5:44", "artists": [_artist("TEST ARTIST (5)", id=11146)], } ], "artists": [ _artist("ARTIST NAME (2)", id=321, join="&"), _artist("OTHER ARTIST (5)", id=321), ], "title": "title", "labels": [ { "name": "LABEL NAME (5)", "catno": "catalog number", } ], } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME & OTHER ARTIST" assert d.artists == ["ARTIST NAME", "OTHER ARTIST"] assert d.artists_ids == ["321", "321"] assert d.tracks[0].artist == "TEST ARTIST" assert d.tracks[0].artists == ["TEST ARTIST"] assert d.tracks[0].artist_id == "11146" assert d.tracks[0].artists_ids == ["11146"] assert d.label == "LABEL NAME" def test_strip_disambiguation_false(self): """Test disabling disambiguation removal from all disambiguated fields.""" config["discogs"]["strip_disambiguation"] = False data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [ { "title": "track", "position": "A", "type_": "track", "duration": "5:44", "artists": [_artist("TEST ARTIST (5)", id=11146)], } ], "artists": [ _artist("ARTIST NAME (2)", id=321, join="&"), _artist("OTHER ARTIST (5)", id=321), ], "title": "title", "labels": [ { "name": "LABEL NAME (5)", "catno": "catalog number", } ], } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) d = DiscogsPlugin().get_album_info(release) assert d.artist == "ARTIST NAME (2) & OTHER ARTIST (5)" assert d.artists == ["ARTIST NAME (2)", "OTHER ARTIST (5)"] assert d.tracks[0].artist == "TEST ARTIST (5)" assert d.tracks[0].artists == ["TEST ARTIST (5)"] assert d.label == "LABEL NAME (5)" config["discogs"]["strip_disambiguation"] = True @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) class DGSearchQueryTest(BeetsTestCase): def test_default_search_filters_without_extra_tags(self): """Discogs search uses only the type filter when no extra_tags are set.""" plugin = DiscogsPlugin() items = [Item()] query, filters = plugin.get_search_query_with_filters( "album", items, "Artist", "Album", False ) assert "Album" in query assert filters == {"type": "release"} def test_extra_tags_populate_discogs_filters(self): """Configured extra_tags should populate Discogs search filters.""" plugin = DiscogsPlugin() plugin.config["extra_tags"] = ["label", "catalognum"] items = [ Item(catalognum="ABC 123", label="abc"), Item(catalognum="ABC 123", label="abc"), Item(catalognum="ABC 123", label="def"), ] _query, filters = plugin.get_search_query_with_filters( "album", items, "Artist", "Album", False ) assert filters["type"] == "release" assert filters["label"] == "abc" # Catalog number should have whitespace removed. assert filters["catno"] == "ABC123" config["discogs"]["extra_tags"] = [] @pytest.mark.parametrize( "track_artist_anv,track_artist,track_artists", [ (False, "ARTIST Feat. PERFORMER", ["ARTIST", "PERFORMER"]), (True, "ART Feat. PERF", ["ART", "PERF"]), ], ) @pytest.mark.parametrize( "album_artist_anv,album_artist,album_artists", [ (False, "DRUMMER, ARTIST & SOLOIST", ["DRUMMER", "ARTIST", "SOLOIST"]), (True, "DRUM, ARTY & SOLO", ["DRUM", "ARTY", "SOLO"]), ], ) @pytest.mark.parametrize( ( "artist_credit_anv,track_artist_credit," "track_artists_credit,album_artist_credit,album_artists_credit" ), [ ( False, "ARTIST Feat. PERFORMER", ["ARTIST", "PERFORMER"], "DRUMMER, ARTIST & SOLOIST", ["DRUMMER", "ARTIST", "SOLOIST"], ), ( True, "ART Feat. PERF", ["ART", "PERF"], "DRUM, ARTY & SOLO", ["DRUM", "ARTY", "SOLO"], ), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv( track_artist_anv, track_artist, track_artists, album_artist_anv, album_artist, album_artists, artist_credit_anv, track_artist_credit, track_artists_credit, album_artist_credit, album_artists_credit, ): """Test using artist name variations.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [ { "title": "track", "position": "A", "type_": "track", "duration": "5:44", "artists": [_artist("ARTIST", id=11146, anv="ART")], "extraartists": [ _artist( "PERFORMER", id=787, role="Featuring", anv="PERF", ) ], } ], "artists": [ _artist("DRUMMER", id=445, anv="DRUM", join=", "), _artist("ARTIST (4)", id=321, anv="ARTY", join="&"), _artist("SOLOIST", id=445, anv="SOLO"), ], "title": "title", } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) config["discogs"]["anv"]["album_artist"] = album_artist_anv config["discogs"]["anv"]["artist"] = track_artist_anv config["discogs"]["anv"]["artist_credit"] = artist_credit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == album_artist assert r.artists == album_artists assert r.artist_credit == album_artist_credit assert r.artists_credit == album_artists_credit assert r.tracks[0].artist == track_artist assert r.tracks[0].artists == track_artists assert r.tracks[0].artist_credit == track_artist_credit assert r.tracks[0].artists_credit == track_artists_credit @pytest.mark.parametrize("artist_anv", [True, False]) @pytest.mark.parametrize("albumartist_anv", [True, False]) @pytest.mark.parametrize("artistcredit_anv", [True, False]) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv_no_variation(artist_anv, albumartist_anv, artistcredit_anv): """Test behavior when there is no ANV but the anv field is set""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [ { "title": "track", "position": "A", "type_": "track", "duration": "5:44", "artists": [_artist("PERFORMER", id=1)], } ], "artists": [_artist("ARTIST", id=2)], "title": "title", } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) config["discogs"]["anv"]["album_artist"] = albumartist_anv config["discogs"]["anv"]["artist"] = artist_anv config["discogs"]["anv"]["artist_credit"] = artistcredit_anv r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" assert r.artists == ["ARTIST"] assert r.artist_credit == "ARTIST" assert r.artists_credit == ["ARTIST"] assert r.tracks[0].artist == "PERFORMER" assert r.tracks[0].artists == ["PERFORMER"] assert r.tracks[0].artist_credit == "PERFORMER" assert r.tracks[0].artists_credit == ["PERFORMER"] @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_anv_album_artist(): """Test using artist name variations when the album artist is the same as the track artist, but only the track artist should use the artist name variation.""" data = { "id": 123, "uri": "https://www.discogs.com/release/123456-something", "tracklist": [ { "title": "track", "position": "A", "type_": "track", "duration": "5:44", } ], "artists": [_artist("ARTIST (4)", id=321, anv="VARIATION")], "title": "title", } release = Bag( data=data, title=data["title"], artists=[Bag(data=d) for d in data["artists"]], ) config["discogs"]["anv"]["album_artist"] = False config["discogs"]["anv"]["artist"] = True config["discogs"]["anv"]["artist_credit"] = False r = DiscogsPlugin().get_album_info(release) assert r.artist == "ARTIST" assert r.artists == ["ARTIST"] assert r.artist_credit == "ARTIST" assert r.artist_id == "321" assert r.artists_credit == ["ARTIST"] assert r.tracks[0].artist == "VARIATION" assert r.tracks[0].artists == ["VARIATION"] assert r.tracks[0].artist_credit == "ARTIST" assert r.tracks[0].artists_credit == ["ARTIST"] @pytest.mark.parametrize( "track, expected_artist, expected_artists", [ ( { "type_": "track", "title": "track", "position": "1", "duration": "5:00", "artists": [ _artist("NEW ARTIST", id=11146, join="&"), _artist("VOCALIST", id=344, join="feat."), ], "extraartists": [ _artist("SOLOIST", id=3, role="Featuring"), _artist( "PERFORMER (1)", id=5, role="Other Role, Featuring" ), _artist("RANDOM", id=8, role="Written-By"), _artist("MUSICIAN", id=10, role="Featuring [Uncredited]"), ], }, "NEW ARTIST & VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN", ["NEW ARTIST", "VOCALIST", "SOLOIST", "PERFORMER", "MUSICIAN"], ), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_parse_featured_artists(track, expected_artist, expected_artists): """Tests the plugins ability to parse a featured artist. Ignores artists that are not listed as featured.""" plugin = DiscogsPlugin() artistinfo = ArtistState.from_config(plugin.config, [_artist("ARTIST")]) t, _, _ = plugin.get_track_info(track, 1, 1, artistinfo) assert t.artist == expected_artist assert t.artists == expected_artists @pytest.mark.parametrize( "formats, expected_media, expected_albumtype", [ (None, None, None), ( [ { "descriptions": ['7"', "Single", "45 RPM"], "name": "Vinyl", "qty": 1, } ], "Vinyl", '7", Single, 45 RPM', ), ], ) def test_get_media_and_albumtype(formats, expected_media, expected_albumtype): result = DiscogsPlugin.get_media_and_albumtype(formats) assert result == (expected_media, expected_albumtype) @pytest.mark.parametrize( "given_artists,expected_info,config_va_name", [ ( [_artist("Various")], { "artist": "VARIOUS ARTISTS", "artist_id": "1", "artists": ["VARIOUS ARTISTS"], "artists_ids": ["1"], "artist_credit": "VARIOUS ARTISTS", "artists_credit": ["VARIOUS ARTISTS"], }, "VARIOUS ARTISTS", ) ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) def test_va_buildartistinfo(given_artists, expected_info, config_va_name): config["va_name"] = config_va_name assert ( ArtistState.from_config(DiscogsPlugin().config, given_artists).info == expected_info ) @pytest.mark.parametrize( "position, medium, index, subindex", [ ("1", None, "1", None), ("A12", "A", "12", None), ("12-34", "12-", "34", None), ("CD1-1", "CD1-", "1", None), ("1.12", None, "1", "12"), ("12.a", None, "12", "A"), ("12.34", None, "12", "34"), ("1ab", None, "1", "AB"), # Non-standard ("IV", "IV", None, None), ], ) def test_get_track_index(position, medium, index, subindex): assert DiscogsPlugin.get_track_index(position) == (medium, index, subindex) ================================================ FILE: test/plugins/test_edit.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import codecs from typing import ClassVar from unittest.mock import patch from beets.dbcore.query import TrueQuery from beets.importer import Action from beets.library import Item from beets.test import _common from beets.test.helper import ( AutotagImportTestCase, AutotagStub, BeetsTestCase, IOMixin, PluginMixin, TerminalImportMixin, ) class ModifyFileMocker: """Helper for modifying a file, replacing or editing its contents. Used for mocking the calls to the external editor during testing. """ def __init__(self, contents=None, replacements=None): """`self.contents` and `self.replacements` are initialized here, in order to keep the rest of the functions of this class with the same signature as `EditPlugin.get_editor()`, making mocking easier. - `contents`: string with the contents of the file to be used for `overwrite_contents()` - `replacement`: dict with the in-place replacements to be used for `replace_contents()`, in the form {'previous string': 'new string'} TODO: check if it can be solved more elegantly with a decorator """ self.contents = contents self.replacements = replacements self.action = self.overwrite_contents if replacements: self.action = self.replace_contents # The two methods below mock the `edit` utility function in the plugin. def overwrite_contents(self, filename, log): """Modify `filename`, replacing its contents with `self.contents`. If `self.contents` is empty, the file remains unchanged. """ if self.contents: with codecs.open(filename, "w", encoding="utf-8") as f: f.write(self.contents) def replace_contents(self, filename, log): """Modify `filename`, reading its contents and replacing the strings specified in `self.replacements`. """ with codecs.open(filename, "r", encoding="utf-8") as f: contents = f.read() for old, new_ in self.replacements.items(): contents = contents.replace(old, new_) with codecs.open(filename, "w", encoding="utf-8") as f: f.write(contents) class EditMixin(PluginMixin): """Helper containing some common functionality used for the Edit tests.""" plugin = "edit" def assertItemFieldsModified( self, library_items, items, fields=[], allowed=["path"] ): """Assert that items in the library (`lib_items`) have different values on the specified `fields` (and *only* on those fields), compared to `items`. An empty `fields` list results in asserting that no modifications have been performed. `allowed` is a list of field changes that are ignored (they may or may not have changed; the assertion doesn't care). """ for lib_item, item in zip(library_items, items): diff_fields = [ field for field in lib_item._fields if lib_item[field] != item[field] ] assert set(diff_fields).difference(allowed) == set(fields) def run_mocked_interpreter(self, modify_file_args={}, stdin=[]): """Run the edit command during an import session, with mocked stdin and yaml writing. """ m = ModifyFileMocker(**modify_file_args) with patch("beetsplug.edit.edit", side_effect=m.action): for char in stdin: self.importer.add_choice(char) self.importer.run() def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): """Run the edit command, with mocked stdin and yaml writing, and passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) with patch("beetsplug.edit.edit", side_effect=m.action): for char in stdin: self.io.addinput(char) self.run_command("edit", *args) @_common.slow_test() @patch("beets.library.Item.write") class EditCommandTest(IOMixin, EditMixin, BeetsTestCase): """Black box tests for `beetsplug.edit`. Command line interaction is simulated using mocked stdin, and yaml editing via an external editor is simulated using `ModifyFileMocker`. """ ALBUM_COUNT = 1 TRACK_COUNT = 10 def setUp(self): super().setUp() # Add an album, storing the original fields for comparison. self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) self.album_orig = {f: self.album[f] for f in self.album._fields} self.items_orig = [ {f: item[f] for f in item._fields} for item in self.album.items() ] def test_title_edit_discard(self, mock_write): """Edit title for all items in the library, then discard changes.""" # Edit track titles. self.run_mocked_command( {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, # Cancel. ["c"], ) assert mock_write.call_count == 0 self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_title_edit_apply(self, mock_write): """Edit title for all items in the library, then apply changes.""" # Edit track titles. self.run_mocked_command( {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, # Apply changes. ["a"], ) assert mock_write.call_count == self.TRACK_COUNT self.assertItemFieldsModified( self.album.items(), self.items_orig, ["title", "mtime"] ) def test_single_title_edit_apply(self, mock_write): """Edit title for one item in the library, then apply changes.""" # Edit one track title. self.run_mocked_command( {"replacements": {"t\u00eftle 9": "modified t\u00eftle 9"}}, # Apply changes. ["a"], ) assert mock_write.call_count == 1 # No changes except on last item. self.assertItemFieldsModified( list(self.album.items())[:-1], self.items_orig[:-1], [] ) assert list(self.album.items())[-1].title == "modified t\u00eftle 9" def test_title_edit_keep_editing_then_apply(self, mock_write): """Edit titles, keep editing once, then apply changes.""" self.run_mocked_command( {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, # keep Editing, then Apply ["e", "a"], ) assert mock_write.call_count == self.TRACK_COUNT self.assertItemFieldsModified( self.album.items(), self.items_orig, ["title", "mtime"], ) def test_title_edit_keep_editing_then_cancel(self, mock_write): """Edit titles, keep editing once, then cancel.""" self.run_mocked_command( {"replacements": {"t\u00eftle": "modified t\u00eftle"}}, # keep Editing, then Cancel ["e", "c"], ) assert mock_write.call_count == 0 self.assertItemFieldsModified( self.album.items(), self.items_orig, [], ) def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. self.run_mocked_command( {"contents": None}, # No stdin. [], ) assert mock_write.call_count == 0 self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_album_edit_apply(self, mock_write): """Edit the album field for all items in the library, apply changes. By design, the album should not be updated."" """ # Edit album. self.run_mocked_command( {"replacements": {"\u00e4lbum": "modified \u00e4lbum"}}, # Apply changes. ["a"], ) assert mock_write.call_count == self.TRACK_COUNT self.assertItemFieldsModified( self.album.items(), self.items_orig, ["album", "mtime"] ) # Ensure album is *not* modified. self.album.load() assert self.album.album == "\u00e4lbum" def test_single_edit_add_field(self, mock_write): """Edit the yaml file appending an extra field to the first item, then apply changes.""" # Append "foo: bar" to item with id == 2. ("id: 1" would match both # "id: 1" and "id: 10") self.run_mocked_command( {"replacements": {"id: 2": "id: 2\nfoo: bar"}}, # Apply changes. ["a"], ) assert self.lib.items("id:2")[0].foo == "bar" # Even though a flexible attribute was written (which is not directly # written to the tags), write should still be called since templates # might use it. assert mock_write.call_count == 1 def test_a_album_edit_apply(self, mock_write): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command( {"replacements": {"\u00e4lbum": "modified \u00e4lbum"}}, # Apply changes. ["a"], args=["-a"], ) self.album.load() assert mock_write.call_count == self.TRACK_COUNT assert self.album.album == "modified \u00e4lbum" self.assertItemFieldsModified( self.album.items(), self.items_orig, ["album", "mtime"] ) def test_a_albumartist_edit_apply(self, mock_write): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command( {"replacements": {"album artist": "modified album artist"}}, # Apply changes. ["a"], args=["-a"], ) self.album.load() assert mock_write.call_count == self.TRACK_COUNT assert self.album.albumartist == "the modified album artist" self.assertItemFieldsModified( self.album.items(), self.items_orig, ["albumartist", "mtime"] ) def test_malformed_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a malformed yaml document).""" # Edit the yaml file to an invalid file. self.run_mocked_command( {"contents": "!MALFORMED"}, # Edit again to fix? No. ["n"], ) assert mock_write.call_count == 0 def test_invalid_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a well-formed but invalid yaml document).""" # Edit the yaml file to an invalid but parseable file. self.run_mocked_command( {"contents": "wellformed: yes, but invalid"}, # No stdin. [], ) assert mock_write.call_count == 0 @_common.slow_test() class EditDuringImporterTestCase( EditMixin, TerminalImportMixin, AutotagImportTestCase ): """TODO""" matching = AutotagStub.GOOD IGNORED: ClassVar[list[str]] = ["added", "album_id", "id", "mtime", "path"] def setUp(self): super().setUp() # Create some mediafiles, and store them for comparison. self.prepare_album_for_import(1) self.items_orig = [Item.from_path(f.path) for f in self.import_media] @_common.slow_test() class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase): def setUp(self): super().setUp() self.importer = self.setup_importer() def test_edit_apply_asis(self): """Edit the album field for all items in the library, apply changes, using the original item tags. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Tag Track": "Edited Track"}}, # eDit, Apply changes. ["d", "a"], ) # Check that only the 'title' field is modified. self.assertItemFieldsModified( self.lib.items(), self.items_orig, ["title"], [ *self.IGNORED, "albumartist", "mb_albumartistid", "mb_albumartistids", ], ) assert all("Edited Track" in i.title for i in self.lib.items()) # Ensure album is *not* fetched from a candidate. assert self.lib.albums()[0].mb_albumid == "" def test_edit_discard_asis(self): """Edit the album field for all items in the library, discard changes, using the original item tags. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Tag Track": "Edited Track"}}, # eDit, Cancel, Use as-is. ["d", "c", "u"], ) # Check that nothing is modified, the album is imported ASIS. self.assertItemFieldsModified( self.lib.items(), self.items_orig, [], [*self.IGNORED, "albumartist", "mb_albumartistid"], ) assert all("Tag Track" in i.title for i in self.lib.items()) # Ensure album is *not* fetched from a candidate. assert self.lib.albums()[0].mb_albumid == "" def test_edit_apply_candidate(self): """Edit the album field for all items in the library, apply changes, using a candidate. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Applied Track": "Edited Track"}}, # edit Candidates, 1, Apply changes. ["c", "1", "a"], ) # Check that 'title' field is modified, and other fields come from # the candidate. assert all("Edited Track " in i.title for i in self.lib.items()) assert all("match " in i.mb_trackid for i in self.lib.items()) # Ensure album is fetched from a candidate. assert "albumid" in self.lib.albums()[0].mb_albumid def test_edit_retag_apply(self): """Import the album using a candidate, then retag and edit and apply changes. """ self.run_mocked_interpreter( {}, # 1, Apply changes. ["1", Action.APPLY], ) # Retag and edit track titles. On retag, the importer will reset items # ids but not the db connections. self.importer.paths = [] self.importer.query = TrueQuery() self.run_mocked_interpreter( {"replacements": {"Applied Track": "Edited Track"}}, # eDit, Apply changes. ["d", "a"], ) # Check that 'title' field is modified, and other fields come from # the candidate. assert all("Edited Track " in i.title for i in self.lib.items()) assert all("match " in i.mb_trackid for i in self.lib.items()) # Ensure album is fetched from a candidate. assert "albumid" in self.lib.albums()[0].mb_albumid def test_edit_discard_candidate(self): """Edit the album field for all items in the library, discard changes, using a candidate. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Applied Track": "Edited Track"}}, # edit Candidates, 1, Apply changes. ["c", "1", "a"], ) # Check that 'title' field is modified, and other fields come from # the candidate. assert all("Edited Track " in i.title for i in self.lib.items()) assert all("match " in i.mb_trackid for i in self.lib.items()) # Ensure album is fetched from a candidate. assert "albumid" in self.lib.albums()[0].mb_albumid def test_edit_apply_candidate_singleton(self): """Edit the album field for all items in the library, apply changes, using a candidate and singleton mode. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Applied Track": "Edited Track"}}, # edit Candidates, 1, Apply changes, aBort. ["c", "1", "a", "b"], ) # Check that 'title' field is modified, and other fields come from # the candidate. assert all("Edited Track " in i.title for i in self.lib.items()) assert all("match " in i.mb_trackid for i in self.lib.items()) @_common.slow_test() class EditDuringImporterSingletonTest(EditDuringImporterTestCase): def setUp(self): super().setUp() self.importer = self.setup_singleton_importer() def test_edit_apply_asis_singleton(self): """Edit the album field for all items in the library, apply changes, using the original item tags and singleton mode. """ # Edit track titles. self.run_mocked_interpreter( {"replacements": {"Tag Track": "Edited Track"}}, # eDit, Apply changes, aBort. ["d", "a", "b"], ) # Check that only the 'title' field is modified. self.assertItemFieldsModified( self.lib.items(), self.items_orig, ["title"], [*self.IGNORED, "albumartist", "mb_albumartistid"], ) assert all("Edited Track" in i.title for i in self.lib.items()) ================================================ FILE: test/plugins/test_embedart.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import os.path import shutil import tempfile import unittest from unittest.mock import MagicMock, patch import pytest from mediafile import MediaFile from beets import config, logging, ui from beets.test import _common from beets.test.helper import ( BeetsTestCase, FetchImageHelper, ImportHelper, IOMixin, PluginMixin, ) from beets.util import bytestring_path, displayable_path, syspath from beets.util.artresizer import ArtResizer from beetsplug._utils import art from test.test_art_resize import DummyIMBackend def require_artresizer_compare(test): def wrapper(*args, **kwargs): if not ArtResizer.shared.can_compare: raise unittest.SkipTest("compare not available") # PHASH computation in ImageMagick changed at some point in an # undocumented way. Check at a low level that comparisons of our # fixtures give the expected results. Only then, plugin logic tests # below are meaningful. # cf. https://github.com/ImageMagick/ImageMagick/discussions/5191 # It would be better to investigate what exactly change in IM and # handle that in ArtResizer.IMBackend.{can_compare,compare}. # Skipping the tests as below is a quick fix to CI, but users may # still see unexpected behaviour. abbey_artpath = os.path.join(_common.RSRC, b"abbey.jpg") abbey_similarpath = os.path.join(_common.RSRC, b"abbey-similar.jpg") abbey_differentpath = os.path.join(_common.RSRC, b"abbey-different.jpg") compare_threshold = 20 similar_compares_ok = ArtResizer.shared.compare( abbey_artpath, abbey_similarpath, compare_threshold, ) different_compares_ok = ArtResizer.shared.compare( abbey_artpath, abbey_differentpath, compare_threshold, ) if not similar_compares_ok or different_compares_ok: raise unittest.SkipTest("IM version with broken compare") return test(*args, **kwargs) wrapper.__name__ = test.__name__ return wrapper class EmbedartCliTest( ImportHelper, IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase ): plugin = "embedart" small_artpath = os.path.join(_common.RSRC, b"image-2x3.jpg") abbey_artpath = os.path.join(_common.RSRC, b"abbey.jpg") abbey_similarpath = os.path.join(_common.RSRC, b"abbey-similar.jpg") abbey_differentpath = os.path.join(_common.RSRC, b"abbey-different.jpg") def _setup_data(self, artpath=None): if not artpath: artpath = self.small_artpath with open(syspath(artpath), "rb") as f: self.image_data = f.read() def test_embed_art_from_file_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data def test_embed_art_from_file_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput("n") self.run_command("embedart", "-f", self.small_artpath) mediafile = MediaFile(syspath(item.path)) # make sure that images array is empty (nothing embedded) assert not mediafile.images def test_embed_art_from_file(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.run_command("embedart", "-y", "-f", self.small_artpath) mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data def test_embed_art_from_album(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] album.artpath = self.small_artpath album.store() self.run_command("embedart", "-y") mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data def test_embed_art_remove_art_file(self): self._setup_data() album = self.add_album_fixture() logging.getLogger("beets.embedart").setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() tmp_path = bytestring_path(tmp_path) os.write(handle, self.image_data) os.close(handle) album.artpath = tmp_path album.store() config["embedart"]["remove_art_file"] = True self.run_command("embedart", "-y") if os.path.isfile(syspath(tmp_path)): os.remove(syspath(tmp_path)) self.fail( f"Artwork file {displayable_path(tmp_path)} was not deleted" ) def test_art_file_missing(self): self.add_album_fixture() logging.getLogger("beets.embedart").setLevel(logging.DEBUG) with pytest.raises(ui.UserError): self.run_command("embedart", "-y", "-f", "/doesnotexist") def test_embed_non_image_file(self): album = self.add_album_fixture() logging.getLogger("beets.embedart").setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() tmp_path = bytestring_path(tmp_path) os.write(handle, b"I am not an image.") os.close(handle) try: self.run_command("embedart", "-y", "-f", tmp_path) finally: os.remove(syspath(tmp_path)) mediafile = MediaFile(syspath(album.items()[0].path)) assert not mediafile.images # No image added. @require_artresizer_compare def test_reject_different_art(self): self._setup_data(self.abbey_artpath) album = self.add_album_fixture() item = album.items()[0] self.run_command("embedart", "-y", "-f", self.abbey_artpath) config["embedart"]["compare_threshold"] = 20 self.run_command("embedart", "-y", "-f", self.abbey_differentpath) mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data, ( f"Image written is not {displayable_path(self.abbey_artpath)}" ) @require_artresizer_compare def test_accept_similar_art(self): self._setup_data(self.abbey_similarpath) album = self.add_album_fixture() item = album.items()[0] self.run_command("embedart", "-y", "-f", self.abbey_artpath) config["embedart"]["compare_threshold"] = 20 self.run_command("embedart", "-y", "-f", self.abbey_similarpath) mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data, ( f"Image written is not {displayable_path(self.abbey_similarpath)}" ) def test_non_ascii_album_path(self): resource_path = os.path.join(_common.RSRC, b"image.mp3") album = self.add_album_fixture() trackpath = album.items()[0].path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command("extractart", "-n", "extracted") assert (album.filepath / "extracted.png").exists() def test_extracted_extension(self): resource_path = os.path.join(_common.RSRC, b"image-jpeg.mp3") album = self.add_album_fixture() trackpath = album.items()[0].path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command("extractart", "-n", "extracted") assert (album.filepath / "extracted.jpg").exists() def test_clear_art_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) embedded_time = os.path.getmtime(syspath(item.path)) self.io.addinput("y") self.run_command("clearart") mediafile = MediaFile(syspath(item.path)) assert not mediafile.images clear_time = os.path.getmtime(syspath(item.path)) assert clear_time > embedded_time # A run on a file without an image should not be modified self.io.addinput("y") self.run_command("clearart") no_clear_time = os.path.getmtime(syspath(item.path)) assert no_clear_time == clear_time def test_clear_art_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput("y") self.run_command("embedart", "-f", self.small_artpath) self.io.addinput("n") self.run_command("clearart") mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.image_data def test_embed_art_from_url_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.mock_response("http://example.com/test.jpg", "image/jpeg") self.io.addinput("y") self.run_command("embedart", "-u", "http://example.com/test.jpg") mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.IMAGEHEADER.get( "image/jpeg" ).ljust(32, b"\x00") def test_embed_art_from_url_png(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.mock_response("http://example.com/test.png", "image/png") self.run_command("embedart", "-y", "-u", "http://example.com/test.png") mediafile = MediaFile(syspath(item.path)) assert mediafile.images[0].data == self.IMAGEHEADER.get( "image/png" ).ljust(32, b"\x00") def test_embed_art_from_url_not_image(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.mock_response("http://example.com/test.html", "text/html") self.run_command("embedart", "-y", "-u", "http://example.com/test.html") mediafile = MediaFile(syspath(item.path)) assert not mediafile.images def test_clearart_on_import_disabled(self): file_path = self.create_mediafile_fixture( images=["jpg"], target_dir=self.import_path ) self.import_media.append(file_path) with self.configure_plugin({"clearart_on_import": False}): importer = self.setup_importer(autotag=False, write=True) importer.run() item = self.lib.items()[0] assert MediaFile(os.path.join(item.path)).images def test_clearart_on_import_enabled(self): file_path = self.create_mediafile_fixture( images=["jpg"], target_dir=self.import_path ) self.import_media.append(file_path) # Force re-init the plugin to register the listener self.unload_plugins() with self.configure_plugin({"clearart_on_import": True}): importer = self.setup_importer(autotag=False, write=True) importer.run() item = self.lib.items()[0] assert not MediaFile(os.path.join(item.path)).images class DummyArtResizer(ArtResizer): """An `ArtResizer` which pretends that ImageMagick is available, and has a sufficiently recent version to support image comparison. """ def __init__(self): self.local_method = DummyIMBackend() @patch("beets.util.artresizer.subprocess") @patch("beetsplug._utils.art.extract") class ArtSimilarityTest(unittest.TestCase): def setUp(self): self.item = _common.item() self.log = logging.getLogger("beets.embedart") self.artresizer = DummyArtResizer() def _similarity(self, threshold): return art.check_art_similarity( self.log, self.item, b"path", threshold, artresizer=self.artresizer, ) def _popen(self, status=0, stdout="", stderr=""): """Create a mock `Popen` object.""" popen = MagicMock(returncode=status) popen.communicate.return_value = stdout, stderr return popen def _mock_popens( self, mock_extract, mock_subprocess, compare_status=0, compare_stdout=b"", compare_stderr=b"", convert_status=0, ): mock_extract.return_value = b"extracted_path" mock_subprocess.Popen.side_effect = [ # The `convert` call. self._popen(convert_status), # The `compare` call. self._popen(compare_status, compare_stdout, compare_stderr), ] def test_compare_success_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, b"10", b"err") assert self._similarity(20) def test_compare_success_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, b"10", b"err") assert not self._similarity(5) def test_compare_status1_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, b"out", b"10") assert self._similarity(20) def test_compare_status1_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, b"out", b"10") assert not self._similarity(5) def test_compare_failed(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 2, b"out", b"10") assert self._similarity(20) is None def test_compare_parsing_error(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, b"foo", b"bar") assert self._similarity(20) is None def test_compare_parsing_error_and_failure( self, mock_extract, mock_subprocess ): self._mock_popens(mock_extract, mock_subprocess, 1, b"foo", b"bar") assert self._similarity(20) is None def test_convert_failure(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, convert_status=1) assert self._similarity(20) is None ================================================ FILE: test/plugins/test_embyupdate.py ================================================ import responses from beets.test.helper import PluginTestCase from beetsplug import embyupdate class EmbyUpdateTest(PluginTestCase): plugin = "embyupdate" def setUp(self): super().setUp() self.config["emby"] = { "host": "localhost", "port": 8096, "username": "username", "password": "password", } def test_api_url_only_name(self): assert ( embyupdate.api_url( self.config["emby"]["host"].get(), self.config["emby"]["port"].get(), "/Library/Refresh", ) == "http://localhost:8096/Library/Refresh?format=json" ) def test_api_url_http(self): assert ( embyupdate.api_url( "http://localhost", self.config["emby"]["port"].get(), "/Library/Refresh", ) == "http://localhost:8096/Library/Refresh?format=json" ) def test_api_url_https(self): assert ( embyupdate.api_url( "https://localhost", self.config["emby"]["port"].get(), "/Library/Refresh", ) == "https://localhost:8096/Library/Refresh?format=json" ) def test_password_data(self): assert embyupdate.password_data( self.config["emby"]["username"].get(), self.config["emby"]["password"].get(), ) == { "username": "username", "password": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "passwordMd5": "5f4dcc3b5aa765d61d8327deb882cf99", } def test_create_header_no_token(self): assert embyupdate.create_headers( "e8837bc1-ad67-520e-8cd2-f629e3155721" ) == { "x-emby-authorization": ( "MediaBrowser " 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } def test_create_header_with_token(self): assert embyupdate.create_headers( "e8837bc1-ad67-520e-8cd2-f629e3155721", token="abc123" ) == { "x-emby-authorization": ( "MediaBrowser " 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ), "x-mediabrowser-token": "abc123", } @responses.activate def test_get_token(self): body = ( '{"User":{"Name":"username", ' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,"EnableSync":true,' '"EnableSyncTranscoding":true,"EnabledDevices":[],' '"EnableAllDevices":true,"EnabledChannels":[],' '"EnableAllChannels":true,"EnabledFolders":[],' '"EnableAllFolders":true,"InvalidLoginAttemptCount":0,' '"EnablePublicSharing":true}},' '"SessionInfo":{"SupportedCommands":[],' '"QueueableMediaTypes":[],"PlayableMediaTypes":[],' '"Id":"89f3b33f8b3a56af22088733ad1d76b3",' '"UserId":"2ec276a2642e54a19b612b9418a8bd3b",' '"UserName":"username","AdditionalUsers":[],' '"ApplicationVersion":"Unknown version",' '"Client":"Unknown app",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"DeviceName":"Unknown device","DeviceId":"Unknown device id",' '"SupportsRemoteControl":false,"PlayState":{"CanSeek":false,' '"IsPaused":false,"IsMuted":false,"RepeatMode":"RepeatNone"}},' '"AccessToken":"4b19180cf02748f7b95c7e8e76562fc8",' '"ServerId":"1efa5077976bfa92bc71652404f646ec"}' ) responses.add( responses.POST, ("http://localhost:8096/Users/AuthenticateByName"), body=body, status=200, content_type="application/json", ) headers = { "x-emby-authorization": ( "MediaBrowser " 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } auth_data = { "username": "username", "password": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "passwordMd5": "5f4dcc3b5aa765d61d8327deb882cf99", } assert ( embyupdate.get_token("http://localhost", 8096, headers, auth_data) == "4b19180cf02748f7b95c7e8e76562fc8" ) @responses.activate def test_get_user(self): body = ( '[{"Name":"username",' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:42:39.3693220Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,' '"EnableSync":true,"EnableSyncTranscoding":true,' '"EnabledDevices":[],"EnableAllDevices":true,' '"EnabledChannels":[],"EnableAllChannels":true,' '"EnabledFolders":[],"EnableAllFolders":true,' '"InvalidLoginAttemptCount":0,"EnablePublicSharing":true}}]' ) responses.add( responses.GET, "http://localhost:8096/Users/Public", body=body, status=200, content_type="application/json", ) response = embyupdate.get_user("http://localhost", 8096, "username") assert response[0]["Id"] == "2ec276a2642e54a19b612b9418a8bd3b" assert response[0]["Name"] == "username" ================================================ FILE: test/plugins/test_export.py ================================================ # This file is part of beets. # Copyright 2019, Carl Suster # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the beets.export utilities associated with the export plugin.""" import json import re # used to test csv format from xml.etree import ElementTree from xml.etree.ElementTree import Element from beets.test.helper import IOMixin, PluginTestCase class ExportPluginTest(IOMixin, PluginTestCase): plugin = "export" def setUp(self): super().setUp() self.test_values = {"title": "xtitle", "album": "xalbum"} def execute_command(self, format_type, artist): query = ",".join(self.test_values.keys()) out = self.run_with_output( "export", "-f", format_type, "-i", query, artist ) return out def create_item(self): (item,) = self.add_item_fixtures() item.artist = "xartist" item.title = self.test_values["title"] item.album = self.test_values["album"] item.write() item.store() return item def test_json_output(self): item1 = self.create_item() out = self.execute_command(format_type="json", artist=item1.artist) json_data = json.loads(out)[0] for key, val in self.test_values.items(): assert key in json_data assert val == json_data[key] def test_jsonlines_output(self): item1 = self.create_item() out = self.execute_command(format_type="jsonlines", artist=item1.artist) json_data = json.loads(out) for key, val in self.test_values.items(): assert key in json_data assert val == json_data[key] def test_csv_output(self): item1 = self.create_item() out = self.execute_command(format_type="csv", artist=item1.artist) csv_list = re.split("\r", re.sub("\n", "", out)) head = re.split(",", csv_list[0]) vals = re.split(",|\r", csv_list[1]) for index, column in enumerate(head): assert self.test_values.get(column, None) is not None assert vals[index] == self.test_values[column] def test_xml_output(self): item1 = self.create_item() out = self.execute_command(format_type="xml", artist=item1.artist) library = ElementTree.fromstring(out) assert isinstance(library, Element) for track in library[0]: for details in track: tag = details.tag txt = details.text assert tag in self.test_values, tag assert self.test_values[tag] == txt, txt ================================================ FILE: test/plugins/test_fetchart.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import ctypes import os import sys from beets import util from beets.test.helper import IOMixin, PluginTestCase class FetchartCliTest(IOMixin, PluginTestCase): plugin = "fetchart" def setUp(self): super().setUp() self.config["fetchart"]["cover_names"] = "c\xc3\xb6ver.jpg" self.config["art_filename"] = "mycover" self.album = self.add_album() self.cover_path = os.path.join(self.album.path, b"mycover.jpg") def check_cover_is_stored(self): assert self.album["artpath"] == self.cover_path with open(util.syspath(self.cover_path)) as f: assert f.read() == "IMAGE" def hide_file_windows(self): hidden_mask = 2 success = ctypes.windll.kernel32.SetFileAttributesW( self.cover_path, hidden_mask ) if not success: self.skipTest("unable to set file attributes") def test_set_art_from_folder(self): self.touch(b"c\xc3\xb6ver.jpg", dir=self.album.path, content="IMAGE") self.run_command("fetchart") self.album.load() self.check_cover_is_stored() def test_filesystem_does_not_pick_up_folder(self): os.makedirs(os.path.join(self.album.path, b"mycover.jpg")) self.run_command("fetchart") self.album.load() assert self.album["artpath"] is None def test_filesystem_does_not_pick_up_ignored_file(self): self.touch(b"co_ver.jpg", dir=self.album.path, content="IMAGE") self.config["ignore"] = ["*_*"] self.run_command("fetchart") self.album.load() assert self.album["artpath"] is None def test_filesystem_picks_up_non_ignored_file(self): self.touch(b"cover.jpg", dir=self.album.path, content="IMAGE") self.config["ignore"] = ["*_*"] self.run_command("fetchart") self.album.load() self.check_cover_is_stored() def test_filesystem_does_not_pick_up_hidden_file(self): self.touch(b".cover.jpg", dir=self.album.path, content="IMAGE") if sys.platform == "win32": self.hide_file_windows() self.config["ignore"] = [] # By default, ignore includes '.*'. self.config["ignore_hidden"] = True self.run_command("fetchart") self.album.load() assert self.album["artpath"] is None def test_filesystem_picks_up_non_hidden_file(self): self.touch(b"cover.jpg", dir=self.album.path, content="IMAGE") self.config["ignore_hidden"] = True self.run_command("fetchart") self.album.load() self.check_cover_is_stored() def test_filesystem_picks_up_hidden_file(self): self.touch(b".cover.jpg", dir=self.album.path, content="IMAGE") if sys.platform == "win32": self.hide_file_windows() self.config["ignore"] = [] # By default, ignore includes '.*'. self.config["ignore_hidden"] = False self.run_command("fetchart") self.album.load() self.check_cover_is_stored() def test_colorization(self): self.config["ui"]["color"] = True out = self.run_with_output("fetchart") assert " - the älbum: \x1b[1;31mno art found\x1b[39;49;00m\n" == out ================================================ FILE: test/plugins/test_filefilter.py ================================================ # This file is part of beets. # Copyright 2016, Malte Ried. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the `filefilter` plugin.""" from beets.test.helper import ImportTestCase, PluginMixin from beets.util import bytestring_path class FileFilterPluginMixin(PluginMixin, ImportTestCase): plugin = "filefilter" preload_plugin = False def setUp(self): super().setUp() self.prepare_tracks_for_import() def prepare_tracks_for_import(self): self.album_track, self.other_album_track, self.single_track = ( bytestring_path(self.prepare_album_for_import(1, album_path=p)[0]) for p in [ self.import_path / "album", self.import_path / "other_album", self.import_path, ] ) self.all_tracks = { self.album_track, self.other_album_track, self.single_track, } def _run(self, config, expected_album_count, expected_paths): with self.configure_plugin(config): self.importer.run() assert len(self.lib.albums()) == expected_album_count assert {i.path for i in self.lib.items()} == expected_paths class FileFilterPluginNonSingletonTest(FileFilterPluginMixin): def setUp(self): super().setUp() self.importer = self.setup_importer(autotag=False, copy=False) def test_import_default(self): """The default configuration should import everything.""" self._run({}, 3, self.all_tracks) def test_import_nothing(self): self._run({"path": "not_there"}, 0, set()) def test_global_config(self): self._run( {"path": ".*album.*"}, 2, {self.album_track, self.other_album_track}, ) def test_album_config(self): self._run( {"album_path": ".*other_album.*"}, 1, {self.other_album_track}, ) def test_singleton_config(self): """Check that singleton configuration is ignored for album import.""" self._run({"singleton_path": ".*other_album.*"}, 3, self.all_tracks) class FileFilterPluginSingletonTest(FileFilterPluginMixin): def setUp(self): super().setUp() self.importer = self.setup_singleton_importer(autotag=False, copy=False) def test_global_config(self): self._run( {"path": ".*album.*"}, 0, {self.album_track, self.other_album_track} ) def test_album_config(self): """Check that album configuration is ignored for singleton import.""" self._run({"album_path": ".*other_album.*"}, 0, self.all_tracks) def test_singleton_config(self): self._run( {"singleton_path": ".*other_album.*"}, 0, {self.other_album_track} ) ================================================ FILE: test/plugins/test_fromfilename.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the fromfilename plugin.""" import pytest from beetsplug import fromfilename class Session: pass class Item: def __init__(self, path): self.path = path self.track = 0 self.artist = "" self.title = "" class Task: def __init__(self, items): self.items = items self.is_album = True @pytest.mark.parametrize( "song1, song2", [ ( ( "/tmp/01 - The Artist - Song One.m4a", 1, "The Artist", "Song One", ), ( "/tmp/02. - The Artist - Song Two.m4a", 2, "The Artist", "Song Two", ), ), ( ("/tmp/01-The_Artist-Song_One.m4a", 1, "The_Artist", "Song_One"), ("/tmp/02.-The_Artist-Song_Two.m4a", 2, "The_Artist", "Song_Two"), ), ( ("/tmp/01 - Song_One.m4a", 1, "", "Song_One"), ("/tmp/02. - Song_Two.m4a", 2, "", "Song_Two"), ), ( ("/tmp/Song One by The Artist.m4a", 0, "The Artist", "Song One"), ("/tmp/Song Two by The Artist.m4a", 0, "The Artist", "Song Two"), ), (("/tmp/01.m4a", 1, "", "01"), ("/tmp/02.m4a", 2, "", "02")), ( ("/tmp/Song One.m4a", 0, "", "Song One"), ("/tmp/Song Two.m4a", 0, "", "Song Two"), ), ], ) def test_fromfilename(song1, song2): """ Each "song" is a tuple of path, expected track number, expected artist, expected title. We use two songs for each test for two reasons: - The plugin needs more than one item to look for uniform strings in paths in order to guess if the string describes an artist or a title. - Sometimes we allow for an optional "." after the track number in paths. """ session = Session() item1 = Item(song1[0]) item2 = Item(song2[0]) task = Task([item1, item2]) f = fromfilename.FromFilenamePlugin() f.filename_task(task, session) assert task.items[0].track == song1[1] assert task.items[0].artist == song1[2] assert task.items[0].title == song1[3] assert task.items[1].track == song2[1] assert task.items[1].artist == song2[2] assert task.items[1].title == song2[3] ================================================ FILE: test/plugins/test_ftintitle.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'ftintitle' plugin.""" from __future__ import annotations from typing import TYPE_CHECKING, TypeAlias import pytest from beets.library.models import Album from beets.test.helper import PluginTestCase from beetsplug import ftintitle if TYPE_CHECKING: from collections.abc import Generator from beets.library.models import Item ConfigValue: TypeAlias = str | bool | list[str] class FtInTitlePluginFunctional(PluginTestCase): plugin = "ftintitle" @pytest.fixture def env() -> Generator[FtInTitlePluginFunctional, None, None]: case = FtInTitlePluginFunctional(methodName="runTest") case.setUp() try: yield case finally: case.tearDown() def set_config( env: FtInTitlePluginFunctional, cfg: dict[str, ConfigValue] | None, ) -> None: cfg = {} if cfg is None else cfg defaults = { "drop": False, "auto": True, "keep_in_artist": False, "custom_words": [], } env.config["ftintitle"].set(defaults) env.config["ftintitle"].set(cfg) def add_item( env: FtInTitlePluginFunctional, path: str, artist: str, title: str, albumartist: str | None, ) -> Item: return env.add_item( path=path, artist=artist, artist_sort=artist, title=title, albumartist=albumartist, ) @pytest.mark.parametrize( "cfg, cmd_args, given, expected", [ pytest.param( None, ("ftintitle",), ("Alice", "Song 1", "Alice"), ("Alice", "Song 1"), id="no-featured-artist", ), pytest.param( {"format": "feat {0}"}, ("ftintitle",), ("Alice ft. Bob", "Song 1", None), ("Alice", "Song 1 feat Bob"), id="no-albumartist-custom-format", ), pytest.param( None, ("ftintitle",), ("Alice", "Song 1", None), ("Alice", "Song 1"), id="no-albumartist-no-feature", ), pytest.param( {"format": "featuring {0}"}, ("ftintitle",), ("Alice ft Bob", "Song 1", "George"), ("Alice", "Song 1 featuring Bob"), id="guest-artist-custom-format", ), pytest.param( None, ("ftintitle",), ("Alice", "Song 1", "George"), ("Alice", "Song 1"), id="guest-artist-no-feature", ), # ---- drop (-d) variants ---- pytest.param( None, ("ftintitle", "-d"), ("Alice ft Bob", "Song 1", "Alice"), ("Alice", "Song 1"), id="drop-self-ft", ), pytest.param( None, ("ftintitle", "-d"), ("Alice", "Song 1", "Alice"), ("Alice", "Song 1"), id="drop-self-no-ft", ), pytest.param( None, ("ftintitle", "-d"), ("Alice ft Bob", "Song 1", "George"), ("Alice", "Song 1"), id="drop-guest-ft", ), pytest.param( None, ("ftintitle", "-d"), ("Alice", "Song 1", "George"), ("Alice", "Song 1"), id="drop-guest-no-ft", ), # ---- custom format variants ---- pytest.param( {"format": "feat. {}"}, ("ftintitle",), ("Alice ft Bob", "Song 1", "Alice"), ("Alice", "Song 1 feat. Bob"), id="custom-format-feat-dot", ), pytest.param( {"format": "featuring {}"}, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice"), ("Alice", "Song 1 featuring Bob"), id="custom-format-featuring", ), pytest.param( {"format": "with {}"}, ("ftintitle",), ("Alice feat Bob", "Song 1", "Alice"), ("Alice", "Song 1 with Bob"), id="custom-format-with", ), # ---- keep_in_artist variants ---- pytest.param( {"format": "feat. {}", "keep_in_artist": True}, ("ftintitle",), ("Alice ft Bob", "Song 1", "Alice"), ("Alice ft Bob", "Song 1 feat. Bob"), id="keep-in-artist-add-to-title", ), pytest.param( {"format": "feat. {}", "keep_in_artist": True}, ("ftintitle", "-d"), ("Alice ft Bob", "Song 1", "Alice"), ("Alice ft Bob", "Song 1"), id="keep-in-artist-drop-from-title", ), # ---- custom_words variants ---- pytest.param( {"format": "featuring {}", "custom_words": ["med"]}, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), ("Alice", "Song 1 featuring Bob"), id="custom-feat-words", ), pytest.param( { "format": "featuring {}", "keep_in_artist": True, "custom_words": ["med"], }, ("ftintitle",), ("Alice med Bob", "Song 1", "Alice"), ("Alice med Bob", "Song 1 featuring Bob"), id="custom-feat-words-keep-in-artists", ), pytest.param( { "format": "featuring {}", "keep_in_artist": True, "custom_words": ["med"], }, ( "ftintitle", "-d", ), ("Alice med Bob", "Song 1", "Alice"), ("Alice med Bob", "Song 1"), id="custom-feat-words-keep-in-artists-drop-from-title", ), # ---- preserve_album_artist variants ---- pytest.param( { "format": "feat. {}", "preserve_album_artist": True, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice"), ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-different-match", ), pytest.param( { "format": "feat. {}", "preserve_album_artist": False, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice"), ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-different-match-b", ), pytest.param( { "format": "feat. {}", "preserve_album_artist": True, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), ("Alice feat. Bob", "Song 1"), id="skip-if-artist-and-album-artists-is-the-same-matching-match", ), pytest.param( { "format": "feat. {}", "preserve_album_artist": False, }, ("ftintitle",), ("Alice feat. Bob", "Song 1", "Alice feat. Bob"), ("Alice", "Song 1 feat. Bob"), id="skip-if-artist-and-album-artists-is-the-same-matching-match-b", ), # ---- titles with brackets/parentheses ---- pytest.param( {"format": "ft. {}", "bracket_keywords": ["mix"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Mix)", "Alice"), ("Alice", "Song 1 ft. Bob (Club Mix)"), id="ft-inserted-before-matching-bracket-keyword", ), pytest.param( {"format": "ft. {}", "bracket_keywords": ["nomatch"]}, ("ftintitle",), ("Alice ft. Bob", "Song 1 (Club Remix)", "Alice"), ("Alice", "Song 1 (Club Remix) ft. Bob"), id="ft-inserted-at-end-no-bracket-keyword-match", ), ], ) def test_ftintitle_functional( env: FtInTitlePluginFunctional, cfg: dict[str, str | bool | list[str]] | None, cmd_args: tuple[str, ...], given: tuple[str, str, str | None], expected: tuple[str, str], ) -> None: set_config(env, cfg) ftintitle.FtInTitlePlugin() artist, title, albumartist = given item = add_item(env, "/", artist, title, albumartist) env.run_command(*cmd_args) item.load() expected_artist, expected_title = expected assert item["artist"] == expected_artist assert item["title"] == expected_title @pytest.mark.parametrize( "artist,albumartist,expected", [ ("Alice ft. Bob", "Alice", "Bob"), ("Alice feat Bob", "Alice", "Bob"), ("Alice featuring Bob", "Alice", "Bob"), ("Alice & Bob", "Alice", "Bob"), ("Alice and Bob", "Alice", "Bob"), ("Alice With Bob", "Alice", "Bob"), ("Alice defeat Bob", "Alice", None), ("Alice & Bob", "Bob", "Alice"), ("Alice ft. Bob", "Bob", "Alice"), ("Alice ft. Carol", "Bob", "Carol"), ], ) def test_find_feat_part( artist: str, albumartist: str, expected: str | None, ) -> None: assert ftintitle.find_feat_part(artist, albumartist) == expected @pytest.mark.parametrize( "given,expected", [ ("Alice ft. Bob", ("Alice", "Bob")), ("Alice feat Bob", ("Alice", "Bob")), ("Alice feat. Bob", ("Alice", "Bob")), ("Alice featuring Bob", ("Alice", "Bob")), ("Alice & Bob", ("Alice", "Bob")), ("Alice, Bob & Charlie", ("Alice", "Bob & Charlie")), ( "Alice, Bob & Charlie feat. Xavier", ("Alice, Bob & Charlie", "Xavier"), ), ("Alice and Bob", ("Alice", "Bob")), ("Alice With Bob", ("Alice", "Bob")), ("Alice defeat Bob", ("Alice defeat Bob", None)), ("Alice & Bob feat Charlie", ("Alice & Bob", "Charlie")), ("Alice & Bob ft. Charlie", ("Alice & Bob", "Charlie")), ("Alice & Bob featuring Charlie", ("Alice & Bob", "Charlie")), ("Alice and Bob feat Charlie", ("Alice and Bob", "Charlie")), ], ) def test_split_on_feat( given: str, expected: tuple[str, str | None], ) -> None: assert ftintitle.split_on_feat(given) == expected @pytest.mark.parametrize( "given,keywords,expected", [ ## default keywords # different braces and keywords ("Song (Remix)", None, "Song ft. Bob (Remix)"), ("Song [Version]", None, "Song ft. Bob [Version]"), ("Song {Extended Mix}", None, "Song ft. Bob {Extended Mix}"), ("Song <Instrumental>", None, "Song ft. Bob <Instrumental>"), # two keyword clauses ("Song (Remix) (Live)", None, "Song ft. Bob (Remix) (Live)"), # brace insensitivity ("Song (Live) [Remix]", None, "Song ft. Bob (Live) [Remix]"), ("Song [Edit] (Remastered)", None, "Song ft. Bob [Edit] (Remastered)"), # negative cases ("Song", None, "Song ft. Bob"), # no clause ("Song (Arbitrary)", None, "Song (Arbitrary) ft. Bob"), # no keyword ("Song (", None, "Song ( ft. Bob"), # no matching brace or keyword ("Song (Live", None, "Song (Live ft. Bob"), # no matching brace with keyword # one keyword clause, one non-keyword clause ("Song (Live) (Arbitrary)", None, "Song ft. Bob (Live) (Arbitrary)"), ("Song (Arbitrary) (Remix)", None, "Song (Arbitrary) ft. Bob (Remix)"), # nested brackets - same type ("Song (Remix (Extended))", None, "Song ft. Bob (Remix (Extended))"), ("Song [Arbitrary [Description]]", None, "Song [Arbitrary [Description]] ft. Bob"), # nested brackets - different types ("Song (Remix [Extended])", None, "Song ft. Bob (Remix [Extended])"), # nested - returns outer start position despite inner keyword ("Song [Arbitrary {Extended}]", None, "Song ft. Bob [Arbitrary {Extended}]"), ("Song {Live <Arbitrary>}", None, "Song ft. Bob {Live <Arbitrary>}"), ("Song <Remaster (Arbitrary)>", None, "Song ft. Bob <Remaster (Arbitrary)>"), ("Song <Extended> [Live]", None, "Song ft. Bob <Extended> [Live]"), ("Song (Version) <Live>", None, "Song ft. Bob (Version) <Live>"), ("Song (Arbitrary [Description])", None, "Song (Arbitrary [Description]) ft. Bob"), ("Song [Description (Arbitrary)]", None, "Song [Description (Arbitrary)] ft. Bob"), ## custom keywords ("Song (Live)", ["live"], "Song ft. Bob (Live)"), ("Song (Concert)", ["concert"], "Song ft. Bob (Concert)"), ("Song (Remix)", ["custom"], "Song (Remix) ft. Bob"), ("Song (Custom)", ["custom"], "Song ft. Bob (Custom)"), ("Song", [], "Song ft. Bob"), ("Song (", [], "Song ( ft. Bob"), # Multi-word keyword tests ("Song (Club Mix)", ["club mix"], "Song ft. Bob (Club Mix)"), # Positive: matches multi-word ("Song (Club Remix)", ["club mix"], "Song (Club Remix) ft. Bob"), # Negative: no match ], ) # fmt: skip def test_insert_ft_into_title( given: str, keywords: list[str] | None, expected: str, ) -> None: assert ( ftintitle.FtInTitlePlugin.insert_ft_into_title( given, "ft. Bob", keywords ) == expected ) @pytest.mark.parametrize( "given,expected", [ ("Alice ft. Bob", True), ("Alice feat. Bob", True), ("Alice feat Bob", True), ("Alice featuring Bob", True), ("Alice (ft. Bob)", True), ("Alice (feat. Bob)", True), ("Alice [ft. Bob]", True), ("Alice [feat. Bob]", True), ("Alice defeat Bob", False), ("Aliceft.Bob", False), ("Alice (defeat Bob)", False), ("Live and Let Go", False), ("Come With Me", False), ], ) def test_contains_feat(given: str, expected: bool) -> None: assert ftintitle.contains_feat(given) is expected @pytest.mark.parametrize( "given,custom_words,expected", [ ("Alice ft. Bob", [], True), ("Alice feat. Bob", [], True), ("Alice feat Bob", [], True), ("Alice featuring Bob", [], True), ("Alice (ft. Bob)", [], True), ("Alice (feat. Bob)", [], True), ("Alice [ft. Bob]", [], True), ("Alice [feat. Bob]", [], True), ("Alice defeat Bob", [], False), ("Aliceft.Bob", [], False), ("Alice (defeat Bob)", [], False), ("Live and Let Go", [], False), ("Come With Me", [], False), ("Alice x Bob", ["x"], True), ("Alice x Bob", ["X"], True), ("Alice och Xavier", ["x"], False), ("Alice ft. Xavier", ["x"], True), ("Alice med Carol", ["med"], True), ("Alice med Carol", [], False), ], ) def test_custom_words( given: str, custom_words: list[str] | None, expected: bool ) -> None: if custom_words is None: custom_words = [] assert ftintitle.contains_feat(given, custom_words) is expected def test_album_template_value(config): config["ftintitle"]["custom_words"] = [] album = Album() album["albumartist"] = "Foo ft. Bar" assert ftintitle._album_artist_no_feat(album) == "Foo" album["albumartist"] = "Foobar" assert ftintitle._album_artist_no_feat(album) == "Foobar" ================================================ FILE: test/plugins/test_fuzzy.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the fuzzy query plugin.""" import pytest from beets.test.helper import PluginMixin, TestHelper @pytest.fixture def helper(request): helper = TestHelper() helper.setup_beets() request.instance.lib = helper.lib request.instance.add_item = helper.add_item yield helper.teardown_beets() @pytest.mark.usefixtures("helper") class TestFuzzyPlugin(PluginMixin): plugin = "fuzzy" @pytest.mark.parametrize( "query,expected_titles", [ pytest.param("~foo", ["seafood"], id="all-fields-substring"), pytest.param("title:~foo", ["seafood"], id="field-substring"), pytest.param("~seafood", ["seafood"], id="all-fields-equal-length"), pytest.param("~zzz", [], id="all-fields-no-match"), ], ) def test_fuzzy_queries(self, query, expected_titles): self.add_item(title="seafood", artist="alpha") self.add_item(title="bread", artist="beta") with self.configure_plugin({}): items = self.lib.items(query) assert [item.title for item in items] == expected_titles ================================================ FILE: test/plugins/test_hook.py ================================================ # This file is part of beets. # Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import annotations import os import sys import unittest from contextlib import contextmanager from typing import TYPE_CHECKING, ClassVar from beets import plugins from beets.test.helper import PluginTestCase, capture_log if TYPE_CHECKING: from collections.abc import Callable, Iterator class HookTestCase(PluginTestCase): plugin = "hook" preload_plugin = False def _get_hook(self, event: str, command: str) -> dict[str, str]: return {"event": event, "command": command} class HookLogsTest(HookTestCase): HOOK: plugins.EventType = "write" @contextmanager def _configure_logs(self, command: str) -> Iterator[list[str]]: config = {"hooks": [self._get_hook(self.HOOK, command)]} with self.configure_plugin(config), capture_log("beets.hook") as logs: plugins.send(self.HOOK) yield logs def test_hook_empty_command(self): with self._configure_logs("") as logs: assert 'hook: invalid command ""' in logs # FIXME: fails on windows @unittest.skipIf(sys.platform == "win32", "win32") def test_hook_non_zero_exit(self): with self._configure_logs('sh -c "exit 1"') as logs: assert f"hook: hook for {self.HOOK} exited with status 1" in logs def test_hook_non_existent_command(self): with self._configure_logs("non-existent-command") as logs: logs = "\n".join(logs) assert f"hook: hook for {self.HOOK} failed: " in logs # The error message is different for each OS. Unfortunately the text is # different in each case, where the only shared text is the string # 'file' and substring 'Err' assert "Err" in logs assert "file" in logs class HookCommandTest(HookTestCase): EVENTS: ClassVar[list[plugins.EventType]] = ["write", "after_write"] def setUp(self): super().setUp() self.paths = [str(self.temp_dir_path / e) for e in self.EVENTS] def _test_command( self, make_test_path: Callable[[str, str], str], send_path_kwarg: bool = False, ) -> None: """Check that each of the configured hooks is executed. Configure hooks for each event: 1. Use the given 'make_test_path' callable to create a test path from the event and the original path. 2. Configure a hook with a command to touch this path. For each of the original paths: 1. Send a test event 2. Assert that a file has been created under the original path, which proves that the configured hook command has been executed. """ events_with_paths = list(zip(self.EVENTS, self.paths)) hooks = [ self._get_hook(e, f"touch {make_test_path(e, p)}") for e, p in events_with_paths ] with self.configure_plugin({"hooks": hooks}): for event, path in events_with_paths: if send_path_kwarg: plugins.send(event, path=path) else: plugins.send(event) assert os.path.isfile(path) @unittest.skipIf(sys.platform == "win32", "win32") def test_hook_no_arguments(self): self._test_command(lambda _, p: p) @unittest.skipIf(sys.platform == "win32", "win32") def test_hook_event_substitution(self): self._test_command(lambda e, p: p.replace(e, "{event}")) @unittest.skipIf(sys.platform == "win32", "win32") def test_hook_argument_substitution(self): self._test_command(lambda *_: "{path}", send_path_kwarg=True) @unittest.skipIf(sys.platform == "win32", "win32") def test_hook_bytes_interpolation(self): self.paths = [p.encode() for p in self.paths] self._test_command(lambda *_: "{path}", send_path_kwarg=True) ================================================ FILE: test/plugins/test_ihate.py ================================================ """Tests for the 'ihate' plugin""" import unittest from beets import importer from beets.library import Item from beetsplug.ihate import IHatePlugin class IHatePluginTest(unittest.TestCase): def test_hate(self): match_pattern = {} test_item = Item( genres=["TestGenre"], album="TestAlbum", artist="TestArtist" ) task = importer.SingletonImportTask(None, test_item) # Empty query should let it pass. assert not IHatePlugin.do_i_hate_this(task, match_pattern) # 1 query match. match_pattern = ["artist:bad_artist", "artist:TestArtist"] assert IHatePlugin.do_i_hate_this(task, match_pattern) # 2 query matches, either should trigger. match_pattern = ["album:test", "artist:testartist"] assert IHatePlugin.do_i_hate_this(task, match_pattern) # Query is blocked by AND clause. match_pattern = ["album:notthis genres:testgenre"] assert not IHatePlugin.do_i_hate_this(task, match_pattern) # Both queries are blocked by AND clause with unmatched condition. match_pattern = [ "album:notthis genres:testgenre", "artist:testartist album:notthis", ] assert not IHatePlugin.do_i_hate_this(task, match_pattern) # Only one query should fire. match_pattern = [ "album:testalbum genres:testgenre", "artist:testartist album:notthis", ] assert IHatePlugin.do_i_hate_this(task, match_pattern) ================================================ FILE: test/plugins/test_importadded.py ================================================ # This file is part of beets. # Copyright 2016, Stig Inge Lea Bjornsen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the `importadded` plugin.""" import os import pytest from beets import importer from beets.test.helper import AutotagImportTestCase, PluginMixin from beets.util import displayable_path, syspath from beetsplug.importadded import ImportAddedPlugin _listeners = ImportAddedPlugin.listeners def preserve_plugin_listeners(): """Preserve the initial plugin listeners as they would otherwise be deleted after the first setup / tear down cycle. """ if not ImportAddedPlugin.listeners: ImportAddedPlugin.listeners = _listeners def modify_mtimes(paths, offset=-60000): for i, path in enumerate(paths, start=1): mstat = os.stat(path) os.utime(syspath(path), (mstat.st_atime, mstat.st_mtime + offset * i)) class ImportAddedTest(PluginMixin, AutotagImportTestCase): # The minimum mtime of the files to be imported plugin = "importadded" min_mtime = None def setUp(self): preserve_plugin_listeners() super().setUp() self.prepare_album_for_import(2) # Different mtimes on the files to be imported in order to test the # plugin modify_mtimes(mfile.path for mfile in self.import_media) self.min_mtime = min( os.path.getmtime(mfile.path) for mfile in self.import_media ) self.importer = self.setup_importer() self.importer.add_choice(importer.Action.APPLY) def find_media_file(self, item): """Find the pre-import MediaFile for an Item""" for m in self.import_media: if m.title.replace("Tag", "Applied") == item.title: return m raise AssertionError( f"No MediaFile found for Item {displayable_path(item.path)}" ) def test_import_album_with_added_dates(self): self.importer.run() album = self.lib.albums().get() assert album.added == self.min_mtime for item in album.items(): assert item.added == self.min_mtime def test_import_album_inplace_with_added_dates(self): self.config["import"]["copy"] = False self.importer.run() album = self.lib.albums().get() assert album.added == self.min_mtime for item in album.items(): assert item.added == self.min_mtime def test_import_album_with_preserved_mtimes(self): self.config["importadded"]["preserve_mtimes"] = True self.importer.run() album = self.lib.albums().get() assert album.added == self.min_mtime for item in album.items(): assert item.added == pytest.approx(self.min_mtime, rel=1e-4) mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) assert item.mtime == pytest.approx(mediafile_mtime, rel=1e-4) assert os.path.getmtime(item.path) == pytest.approx( mediafile_mtime, rel=1e-4 ) def test_reimported_album_skipped(self): # Import and record the original added dates self.importer.run() album = self.lib.albums().get() album_added_before = album.added items_added_before = {item.path: item.added for item in album.items()} # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport self.setup_importer(import_dir=self.libdir) self.importer.run() # Verify the reimported items album = self.lib.albums().get() assert album.added == pytest.approx(album_added_before, rel=1e-4) items_added_after = {item.path: item.added for item in album.items()} for item_path, added_after in items_added_after.items(): assert items_added_before[item_path] == pytest.approx( added_after, rel=1e-4 ), f"reimport modified Item.added for {displayable_path(item_path)}" def test_import_singletons_with_added_dates(self): self.config["import"]["singletons"] = True self.importer.run() for item in self.lib.items(): mfile = self.find_media_file(item) assert item.added == pytest.approx( os.path.getmtime(mfile.path), rel=1e-4 ) def test_import_singletons_with_preserved_mtimes(self): self.config["import"]["singletons"] = True self.config["importadded"]["preserve_mtimes"] = True self.importer.run() for item in self.lib.items(): mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) assert item.added == pytest.approx(mediafile_mtime, rel=1e-4) assert item.mtime == pytest.approx(mediafile_mtime, rel=1e-4) assert os.path.getmtime(item.path) == pytest.approx( mediafile_mtime, rel=1e-4 ) def test_reimported_singletons_skipped(self): self.config["import"]["singletons"] = True # Import and record the original added dates self.importer.run() items_added_before = { item.path: item.added for item in self.lib.items() } # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport self.setup_importer(import_dir=self.libdir, singletons=True) self.importer.run() # Verify the reimported items items_added_after = {item.path: item.added for item in self.lib.items()} for item_path, added_after in items_added_after.items(): assert items_added_before[item_path] == pytest.approx( added_after, rel=1e-4 ), f"reimport modified Item.added for {displayable_path(item_path)}" ================================================ FILE: test/plugins/test_importfeeds.py ================================================ import datetime import os from beets.library import Album, Item from beets.test.helper import PluginTestCase from beetsplug.importfeeds import ImportFeedsPlugin class ImportFeedsTest(PluginTestCase): plugin = "importfeeds" def setUp(self): super().setUp() self.importfeeds = ImportFeedsPlugin() self.feeds_dir = self.temp_dir_path / "importfeeds" self.config["importfeeds"]["dir"] = str(self.feeds_dir) def test_multi_format_album_playlist(self): self.config["importfeeds"]["formats"] = "m3u_multi" album = Album(album="album/name", id=1) item_path = os.path.join("path", "to", "item") item = Item(title="song", album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist_path = self.feeds_dir / next(self.feeds_dir.iterdir()) assert str(playlist_path).endswith("album_name.m3u") with open(playlist_path) as playlist: assert item_path in playlist.read() def test_playlist_in_subdir(self): self.config["importfeeds"]["formats"] = "m3u" self.config["importfeeds"]["m3u_name"] = os.path.join( "subdir", "imported.m3u" ) album = Album(album="album/name", id=1) item_path = os.path.join("path", "to", "item") item = Item(title="song", album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist = self.feeds_dir / self.config["importfeeds"]["m3u_name"].get() playlist_subdir = os.path.dirname(playlist) assert os.path.isdir(playlist_subdir) assert os.path.isfile(playlist) def test_playlist_per_session(self): self.config["importfeeds"]["formats"] = "m3u_session" self.config["importfeeds"]["m3u_name"] = "imports.m3u" album = Album(album="album/name", id=1) item_path = os.path.join("path", "to", "item") item = Item(title="song", album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.import_begin(self) self.importfeeds.album_imported(self.lib, album) date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M") playlist = self.feeds_dir / f"imports_{date}.m3u" assert os.path.isfile(playlist) with open(playlist) as playlist_contents: assert item_path in playlist_contents.read() ================================================ FILE: test/plugins/test_importsource.py ================================================ # This file is part of beets. # Copyright 2025, Stig Inge Lea Bjornsen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the `importsource` plugin.""" import os import time from beets import importer, plugins from beets.test.helper import AutotagImportTestCase, IOMixin, PluginMixin from beets.util import syspath from beetsplug.importsource import ImportSourcePlugin _listeners = ImportSourcePlugin.listeners def preserve_plugin_listeners(): """Preserve the initial plugin listeners as they would otherwise be deleted after the first setup / tear down cycle. """ if not ImportSourcePlugin.listeners: ImportSourcePlugin.listeners = _listeners class ImportSourceTest(IOMixin, PluginMixin, AutotagImportTestCase): plugin = "importsource" preload_plugin = False def setUp(self): preserve_plugin_listeners() super().setUp() self.config[self.plugin]["suggest_removal"] = True self.load_plugins() self.prepare_album_for_import(2) self.importer = self.setup_importer() self.importer.add_choice(importer.Action.APPLY) self.importer.run() self.all_items = self.lib.albums().get().items() self.item_to_remove = self.all_items[0] def interact(self, stdin: list[str]): for char in stdin: self.io.addinput(char) self.run_command("remove", f"path:{syspath(self.item_to_remove.path)}") def test_do_nothing(self): self.interact(["N"]) assert os.path.exists(self.item_to_remove.source_path) def test_remove_single(self): self.interact(["y", "D"]) assert not os.path.exists(self.item_to_remove.source_path) def test_remove_all_from_single(self): self.interact(["y", "R", "y"]) for item in self.all_items: assert not os.path.exists(item.source_path) def test_stop_suggesting(self): self.interact(["y", "S"]) for item in self.all_items: assert os.path.exists(item.source_path) def test_source_path_attribute_written(self): """Test that source_path attribute is correctly written to imported items. The items should already have source_path from the setUp import """ for item in self.all_items: assert "source_path" in item assert item.source_path # Should not be empty def test_source_files_not_modified_during_import(self): """Test that source files timestamps are not changed during import.""" # Prepare fresh files and record timestamps test_album_path = self.import_path / "test_album" import_paths = self.prepare_album_for_import( 2, album_path=test_album_path ) original_mtimes = { path: os.stat(path).st_mtime for path in import_paths } # Small delay to detect timestamp changes time.sleep(0.1) # Run a fresh import importer_session = self.setup_importer() importer_session.add_choice(importer.Action.APPLY) importer_session.run() # Verify timestamps haven't changed for path, original_mtime in original_mtimes.items(): current_mtime = os.stat(path).st_mtime assert current_mtime == original_mtime, ( f"Source file timestamp changed: {path}" ) def test_prevent_suggest_removal_on_reimport(self): """Test that removal suggestions are prevented during reimport.""" album = self.lib.albums().get() mb_albumid = album.mb_albumid # Reimport from library reimporter = self.setup_importer(import_dir=self.libdir) reimporter.add_choice(importer.Action.APPLY) reimporter.run() plugin = plugins._instances[0] assert mb_albumid in plugin.stop_suggestions_for_albums # Calling suggest_removal should exit early without prompting item = self.lib.items().get() plugin.suggest_removal(item) assert os.path.exists(item.source_path) def test_prevent_suggest_removal_handles_skipped_task(self): """Test that skipped tasks don't crash prevent_suggest_removal.""" class MockTask: skip = True def imported_items(self): return "whatever" plugin = plugins._instances[0] mock_task = MockTask() plugin.prevent_suggest_removal(None, mock_task) ================================================ FILE: test/plugins/test_info.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from mediafile import MediaFile from beets.test.helper import IOMixin, PluginTestCase from beets.util import displayable_path class InfoTest(IOMixin, PluginTestCase): plugin = "info" def test_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) mediafile.albumartist = "AAA" mediafile.disctitle = "DDD" mediafile.genres = ["a", "b", "c"] mediafile.composer = None mediafile.save() out = self.run_with_output("info", path) assert displayable_path(path) in out assert "albumartist: AAA" in out assert "disctitle: DDD" in out assert "genres: a; b; c" in out assert "composer:" not in out def test_item_query(self): item1, item2 = self.add_item_fixtures(count=2) item1.album = "xxxx" item1.write() item1.album = "yyyy" item1.store() out = self.run_with_output("info", "album:yyyy") assert displayable_path(item1.path) in out assert "album: xxxx" in out assert displayable_path(item2.path) not in out def test_item_library_query(self): (item,) = self.add_item_fixtures() item.album = "xxxx" item.store() out = self.run_with_output("info", "--library", "album:xxxx") assert displayable_path(item.path) in out assert "album: xxxx" in out def test_collect_item_and_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) (item,) = self.add_item_fixtures() item.album = mediafile.album = "AAA" item.tracktotal = mediafile.tracktotal = 5 item.title = "TTT" mediafile.title = "SSS" item.write() item.store() mediafile.save() out = self.run_with_output("info", "--summarize", "album:AAA", path) assert "album: AAA" in out assert "tracktotal: 5" in out assert "title: [various]" in out def test_collect_item_and_path_with_multi_values(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) (item,) = self.add_item_fixtures() item.album = mediafile.album = "AAA" item.tracktotal = mediafile.tracktotal = 5 item.title = "TTT" mediafile.title = "SSS" item.albumartists = ["Artist A", "Artist B"] mediafile.albumartists = ["Artist C", "Artist D"] item.artists = ["Artist A", "Artist Z"] mediafile.artists = ["Artist A", "Artist Z"] item.write() item.store() mediafile.save() out = self.run_with_output("info", "--summarize", "album:AAA", path) assert "album: AAA" in out assert "tracktotal: 5" in out assert "title: [various]" in out assert "albumartists: [various]" in out assert "artists: Artist A; Artist Z" in out def test_custom_format(self): self.add_item_fixtures() out = self.run_with_output( "info", "--library", "--format", "$track. $title - $artist ($length)", ) assert "02. tïtle 0 - the artist (0:01)\n" == out ================================================ FILE: test/plugins/test_inline.py ================================================ # This file is part of beets. # Copyright 2025, Gabe Push. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from beets import config, plugins from beets.test.helper import PluginTestCase from beetsplug.inline import InlinePlugin class TestInlineRecursion(PluginTestCase): def test_no_recursion_when_inline_shadows_fixed_field(self): config["plugins"] = ["inline"] config["item_fields"] = { "track_no": ( "f'{disc:02d}-{track:02d}' if disctotal > 1 else f'{track:02d}'" ) } plugins._instances.clear() plugins.load_plugins() item = self.add_item_fixture( artist="Artist", album="Album", title="Title", track=1, disc=1, disctotal=1, ) out = item.evaluate_template("$track_no") assert out == "01" def test_inline_function_body_item_field(self): plugin = InlinePlugin() func = plugin.compile_inline( "return track + 1", album=False, field_name="next_track" ) item = self.add_item_fixture(track=3) assert func(item) == 4 def test_inline_album_expression_uses_items(self): plugin = InlinePlugin() func = plugin.compile_inline( "len(items)", album=True, field_name="item_count" ) album = self.add_album_fixture() assert func(album) == len(list(album.items())) ================================================ FILE: test/plugins/test_ipfs.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os from unittest.mock import Mock, patch from beets.test import _common from beets.test.helper import PluginTestCase from beets.util import bytestring_path from beetsplug.ipfs import IPFSPlugin @patch("beets.util.command_output", Mock()) class IPFSPluginTest(PluginTestCase): plugin = "ipfs" def test_stored_hashes(self): test_album = self.mk_test_album() ipfs = IPFSPlugin() added_albums = ipfs.ipfs_added_albums(self.lib, self.lib.path) added_album = added_albums.get_album(1) assert added_album.ipfs == test_album.ipfs found = False want_item = test_album.items()[2] for check_item in added_album.items(): try: if check_item.get("ipfs", with_album=False): ipfs_item = os.fsdecode(os.path.basename(want_item.path)) want_path = f"/ipfs/{test_album.ipfs}/{ipfs_item}" want_path = bytestring_path(want_path) assert check_item.path == want_path assert ( check_item.get("ipfs", with_album=False) == want_item.ipfs ) assert check_item.title == want_item.title found = True except AttributeError: pass assert found def mk_test_album(self): items = [_common.item() for _ in range(3)] items[0].title = "foo bar" items[0].artist = "1one" items[0].album = "baz" items[0].year = 2001 items[0].comp = True items[1].title = "baz qux" items[1].artist = "2two" items[1].album = "baz" items[1].year = 2002 items[1].comp = True items[2].title = "beets 4 eva" items[2].artist = "3three" items[2].album = "foo" items[2].year = 2003 items[2].comp = False items[2].ipfs = "QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSk" for item in items: self.lib.add(item) album = self.lib.add_album(items) album.ipfs = "QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSf" album.store(inherit=False) return album ================================================ FILE: test/plugins/test_keyfinder.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from unittest.mock import patch from beets import util from beets.library import Item from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin @patch("beets.util.command_output") class KeyFinderTest(AsIsImporterMixin, PluginMixin, ImportTestCase): plugin = "keyfinder" def test_add_key(self, command_output): item = Item(path="/file") item.add(self.lib) command_output.return_value = util.CommandOutput(b"dbm", b"") self.run_command("keyfinder") item.load() assert item["initial_key"] == "C#m" command_output.assert_called_with( ["KeyFinder", "-f", util.syspath(item.path)] ) def test_add_key_on_import(self, command_output): command_output.return_value = util.CommandOutput(b"dbm", b"") self.run_asis_importer() item = self.lib.items().get() assert item["initial_key"] == "C#m" def test_force_overwrite(self, command_output): self.config["keyfinder"]["overwrite"] = True item = Item(path="/file", initial_key="F") item.add(self.lib) command_output.return_value = util.CommandOutput(b"C#m", b"") self.run_command("keyfinder") item.load() assert item["initial_key"] == "C#m" def test_do_not_overwrite(self, command_output): item = Item(path="/file", initial_key="F") item.add(self.lib) command_output.return_value = util.CommandOutput(b"dbm", b"") self.run_command("keyfinder") item.load() assert item["initial_key"] == "F" def test_no_key(self, command_output): item = Item(path="/file") item.add(self.lib) command_output.return_value = util.CommandOutput(b"", b"") self.run_command("keyfinder") item.load() assert item["initial_key"] is None ================================================ FILE: test/plugins/test_lastgenre.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'lastgenre' plugin.""" from unittest.mock import Mock, patch import pytest from beets.test import _common from beets.test.helper import IOMixin, PluginTestCase from beetsplug import lastgenre class LastGenrePluginTest(IOMixin, PluginTestCase): plugin = "lastgenre" def setUp(self): super().setUp() self.plugin = lastgenre.LastGenrePlugin() def _setup_config( self, whitelist=False, canonical=False, count=1, prefer_specific=False ): self.config["lastgenre"]["canonical"] = canonical self.config["lastgenre"]["count"] = count self.config["lastgenre"]["prefer_specific"] = prefer_specific if isinstance(whitelist, (bool, (str,))): # Filename, default, or disabled. self.config["lastgenre"]["whitelist"] = whitelist self.plugin.setup() if not isinstance(whitelist, (bool, (str,))): # Explicit list of genres. self.plugin.whitelist = whitelist def test_default(self): """Fetch genres with whitelist and c14n deactivated""" self._setup_config() assert self.plugin._resolve_genres(["delta blues"]) == ["delta blues"] def test_c14n_only(self): """Default c14n tree funnels up to most common genre except for *wrong* genres that stay unchanged. """ self._setup_config(canonical=True, count=99) assert self.plugin._resolve_genres(["delta blues"]) == ["blues"] assert self.plugin._resolve_genres(["iota blues"]) == ["iota blues"] def test_whitelist_only(self): """Default whitelist rejects *wrong* (non existing) genres.""" self._setup_config(whitelist=True) assert self.plugin._resolve_genres(["iota blues"]) == [] def test_whitelist_c14n(self): """Default whitelist and c14n both activated result in all parents genres being selected (from specific to common). """ self._setup_config(canonical=True, whitelist=True, count=99) assert self.plugin._resolve_genres(["delta blues"]) == [ "delta blues", "blues", ] def test_whitelist_custom(self): """Keep only genres that are in the whitelist.""" self._setup_config(whitelist={"blues", "rock", "jazz"}, count=2) assert self.plugin._resolve_genres(["pop", "blues"]) == ["blues"] self._setup_config(canonical="", whitelist={"rock"}) assert self.plugin._resolve_genres(["delta blues"]) == [] def test_format_genres(self): """Format genres list.""" self._setup_config(count=2) assert self.plugin._format_genres(["jazz", "pop", "rock", "blues"]) == [ "Jazz", "Pop", "Rock", "Blues", ] def test_count_c14n(self): """Keep the n first genres, after having applied c14n when necessary""" self._setup_config( whitelist={"blues", "rock", "jazz"}, canonical=True, count=2 ) # thanks to c14n, 'blues' superseeds 'country blues' and takes the # second slot assert self.plugin._resolve_genres( ["jazz", "pop", "country blues", "rock"] ) == ["jazz", "blues"] def test_c14n_whitelist(self): """Genres first pass through c14n and are then filtered""" self._setup_config(canonical=True, whitelist={"rock"}) assert self.plugin._resolve_genres(["delta blues"]) == [] def test_empty_string_enables_canonical(self): """For backwards compatibility, setting the `canonical` option to the empty string enables it using the default tree. """ self._setup_config(canonical="", count=99) assert self.plugin._resolve_genres(["delta blues"]) == ["blues"] def test_empty_string_enables_whitelist(self): """Again for backwards compatibility, setting the `whitelist` option to the empty string enables the default set of genres. """ self._setup_config(whitelist="") assert self.plugin._resolve_genres(["iota blues"]) == [] def test_prefer_specific_loads_tree(self): """When prefer_specific is enabled but canonical is not the tree still has to be loaded. """ self._setup_config(prefer_specific=True, canonical=False) assert self.plugin.c14n_branches != [] def test_prefer_specific_without_canonical(self): """Prefer_specific works without canonical.""" self._setup_config(prefer_specific=True, canonical=False, count=4) assert self.plugin._resolve_genres(["math rock", "post-rock"]) == [ "post-rock", "math rock", ] @patch("beets.ui.should_write", Mock(return_value=True)) @patch( "beetsplug.lastgenre.LastGenrePlugin._get_genre", Mock(return_value=("Mock Genre", "mock stage")), ) def test_pretend_option_skips_library_updates(self): item = self.create_item( album="Pretend Album", albumartist="Pretend Artist", artist="Pretend Artist", title="Pretend Track", genres=["Original Genre"], ) album = self.lib.add_album([item]) def unexpected_store(*_, **__): raise AssertionError("Unexpected store call") # Verify that try_write was never called (file operations skipped) with patch("beetsplug.lastgenre.Item.store", unexpected_store): output = self.run_with_output("lastgenre", "--pretend") assert "genres:" in output album.load() assert album.genres == ["Original Genre"] assert album.items()[0].genres == ["Original Genre"] def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) assert self.plugin._resolve_genres(["blues", "blues"]) == ["blues"] def test_tags_for(self): class MockPylastElem: def __init__(self, name): self.name = name def get_name(self): return self.name class MockPylastObj: def get_top_tags(self): tag1 = Mock() tag1.weight = 90 tag1.item = MockPylastElem("Pop") tag2 = Mock() tag2.weight = 40 tag2.item = MockPylastElem("Rap") return [tag1, tag2] plugin = lastgenre.LastGenrePlugin() res = plugin.client._tags_for(MockPylastObj()) assert res == ["pop", "rap"] res = plugin.client._tags_for(MockPylastObj(), min_weight=50) assert res == ["pop"] def test_sort_by_depth(self): self._setup_config(canonical=True) # Normal case. tags = ("electronic", "ambient", "post-rock", "downtempo") res = lastgenre.sort_by_depth(tags, self.plugin.c14n_branches) assert res == ["post-rock", "downtempo", "ambient", "electronic"] # Non-canonical tag ('chillout') present. tags = ("electronic", "ambient", "chillout") res = lastgenre.sort_by_depth(tags, self.plugin.c14n_branches) assert res == ["ambient", "electronic"] @pytest.fixture def config(config): """Provide a fresh beets configuration for every test/parameterize call This is necessary to prevent the following parameterized test to bleed config test state in between test cases. """ return config @pytest.mark.parametrize( "config_values, item_genre, mock_genres, expected_result", [ # force and keep whitelisted ( { "force": True, "keep_existing": True, "source": "album", # means album or artist genre "whitelist": True, "canonical": False, "prefer_specific": False, "count": 10, }, ["Blues"], { "album": ["Jazz"], }, (["Blues", "Jazz"], "keep + album, whitelist"), ), # force and keep whitelisted, unknown original ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "canonical": False, "prefer_specific": False, "count": 10, }, ["original unknown", "Blues"], { "album": ["Jazz"], }, (["Blues", "Jazz"], "keep + album, whitelist"), ), # force and keep whitelisted on empty tag ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "canonical": False, "prefer_specific": False, }, [], { "album": ["Jazz"], }, (["Jazz"], "album, whitelist"), ), # force and keep, artist configured ( { "force": True, "keep_existing": True, "source": "artist", # means artist genre, original or fallback "whitelist": True, "canonical": False, "prefer_specific": False, "count": 10, }, ["original unknown", "Blues"], { "album": ["Jazz"], "artist": ["Pop"], }, (["Blues", "Pop"], "keep + artist, whitelist"), ), # don't force, disabled whitelist ( { "force": False, "keep_existing": False, "source": "album", "whitelist": False, "canonical": False, "prefer_specific": False, }, ["any genre"], { "album": ["Jazz"], }, (["any genre"], "keep any, no-force"), ), # don't force and empty is regular last.fm fetch; no whitelist too ( { "force": False, "keep_existing": False, "source": "album", "whitelist": False, "canonical": False, "prefer_specific": False, }, [], { "album": ["Jazzin"], }, (["Jazzin"], "album, any"), ), # Canonicalize original genre when force is **off** and # whitelist, canonical and cleanup_existing are on. # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the # tree to "Disco" and "Electronic". ( { "force": False, "keep_existing": False, "source": "artist", "whitelist": True, "canonical": True, "cleanup_existing": True, "prefer_specific": False, "count": 10, }, ["Cosmic Disco"], { "artist": [], }, ( ["Disco", "Electronic"], "keep + cleanup, whitelist", ), ), # fallback to next stages until found ( { "force": True, "keep_existing": True, "source": "track", # means track,album,artist,... "whitelist": False, "canonical": False, "prefer_specific": False, "count": 10, }, ["unknown genre"], { "track": None, "album": None, "artist": ["Jazz"], }, (["Unknown Genre", "Jazz"], "keep + artist, any"), ), # Keep the original genre when force and keep_existing are on, and # whitelist is disabled ( { "force": True, "keep_existing": True, "source": "track", "whitelist": False, "fallback": "fallback genre", "canonical": False, "prefer_specific": False, }, ["any existing"], { "track": None, "album": None, "artist": None, }, (["any existing"], "original fallback"), ), # Keep the original genre when force and keep_existing are on, and # whitelist is enabled, and genre is valid. ( { "force": True, "keep_existing": True, "source": "track", "whitelist": True, "fallback": "fallback genre", "canonical": False, "prefer_specific": False, }, ["Jazz"], { "track": None, "album": None, "artist": None, }, (["Jazz"], "original fallback"), ), # Return the configured fallback when force is on but # keep_existing is not. ( { "force": True, "keep_existing": False, "source": "track", "whitelist": True, "fallback": "fallback genre", "canonical": False, "prefer_specific": False, }, ["Jazz"], { "track": None, "album": None, "artist": None, }, (["fallback genre"], "fallback"), ), # fallback to fallback if no original ( { "force": True, "keep_existing": True, "source": "track", "whitelist": True, "fallback": "fallback genre", "canonical": False, "prefer_specific": False, }, [], { "track": None, "album": None, "artist": None, }, (["fallback genre"], "fallback"), ), # limit a lot of results ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "count": 5, "canonical": False, "prefer_specific": False, }, ["original unknown", "Blues", "Rock", "Folk", "Metal"], { "album": ["Jazz", "Bebop", "Hardbop"], }, ( ["Blues", "Rock", "Metal", "Jazz", "Bebop"], "keep + album, whitelist", ), ), # fallback to next stage (artist) if no allowed original present # and no album genre were fetched. ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "fallback": "fallback genre", "canonical": False, "prefer_specific": False, }, ["not whitelisted original"], { "track": None, "album": None, "artist": ["Jazz"], }, (["Jazz"], "keep + artist, whitelist"), ), # canonicalization transforms non-whitelisted genres to canonical forms # # "Acid Techno" is not in the default whitelist, thus gets resolved "up" in the # tree to "Techno" and "Electronic". ( { "force": True, "keep_existing": False, "source": "album", "whitelist": True, "canonical": True, "prefer_specific": False, "count": 10, }, [], { "album": ["acid techno"], }, (["Techno", "Electronic"], "album, whitelist"), ), # canonicalization transforms whitelisted genres to canonical forms and # includes originals # # "Detroit Techno" is in the default whitelist, thus it stays and and also gets # resolved "up" in the tree to "Techno" and "Electronic". The same happens for # newly fetched genre "Acid House". ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "canonical": True, "prefer_specific": False, "count": 10, "extended_debug": True, }, ["detroit techno"], { "album": ["acid house"], }, ( [ "Detroit Techno", "Techno", "Electronic", "Acid House", "House", ], "keep + album, whitelist", ), ), # canonicalization transforms non-whitelisted original genres to canonical # forms and deduplication works. # # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the # tree to "Disco" and "Electronic". New genre "Detroit Techno" resolves to # "Techno". Both resolve to "Electronic" which gets deduplicated. ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "canonical": True, "prefer_specific": False, "count": 10, }, ["Cosmic Disco"], { "album": ["Detroit Techno"], }, ( ["Disco", "Electronic", "Detroit Techno", "Techno"], "keep + album, whitelist", ), ), # canonicalization transforms non-whitelisted original genres to canonical # forms and deduplication works, **even** when no new genres are found online. # # "Cosmic Disco" is not in the default whitelist, thus gets resolved "up" in the # tree to "Disco" and "Electronic". ( { "force": True, "keep_existing": True, "source": "album", "whitelist": True, "canonical": True, "prefer_specific": False, "count": 10, }, ["Cosmic Disco"], { "album": [], "artist": [], }, ( ["Disco", "Electronic"], "keep + original fallback, whitelist", ), ), ], ) def test_get_genre( config, config_values, item_genre, mock_genres, expected_result ): """Test _get_genre with various configurations.""" def mock_fetch_track_genre(self, trackartist, tracktitle): return mock_genres["track"] def mock_fetch_album_genre(self, albumartist, albumtitle): return mock_genres["album"] def mock_fetch_artist_genre(self, artist): return mock_genres["artist"] # Mock the last.fm fetchers. When whitelist enabled, we can assume only # whitelisted genres get returned, the plugin's _resolve_genre method # ensures it. lastgenre.client.LastFmClient.fetch_track_genre = mock_fetch_track_genre lastgenre.client.LastFmClient.fetch_album_genre = mock_fetch_album_genre lastgenre.client.LastFmClient.fetch_artist_genre = mock_fetch_artist_genre # Initialize plugin instance and item plugin = lastgenre.LastGenrePlugin() # Configure plugin.config.set(config_values) plugin.setup() # Loads default whitelist and canonicalization tree item = _common.item() item.genres = item_genre # Run assert plugin._get_genre(item) == expected_result ================================================ FILE: test/plugins/test_limit.py ================================================ # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'limit' plugin.""" from beets.test.helper import IOMixin, PluginTestCase class LimitPluginTest(IOMixin, PluginTestCase): """Unit tests for LimitPlugin Note: query prefix tests do not work correctly with `run_with_output`. """ plugin = "limit" def setUp(self): super().setUp() # we'll create an even number of tracks in the library self.num_test_items = 10 assert self.num_test_items % 2 == 0 for item_no, item in enumerate( self.add_item_fixtures(count=self.num_test_items) ): item.track = item_no + 1 item.store() # our limit tests will use half of this number self.num_limit = self.num_test_items // 2 self.num_limit_prefix = "".join(["'", "<", str(self.num_limit), "'"]) # a subset of tests has only `num_limit` results, identified by a # range filter on the track number self.track_head_range = f"track:..{self.num_limit}" self.track_tail_range = f"track:{self.num_limit + 1}{'..'}" def test_no_limit(self): """Returns all when there is no limit or filter.""" result = self.run_with_output("lslimit") assert result.count("\n") == self.num_test_items def test_lslimit_head(self): """Returns the expected number with `lslimit --head`.""" result = self.run_with_output("lslimit", "--head", str(self.num_limit)) assert result.count("\n") == self.num_limit def test_lslimit_tail(self): """Returns the expected number with `lslimit --tail`.""" result = self.run_with_output("lslimit", "--tail", str(self.num_limit)) assert result.count("\n") == self.num_limit def test_lslimit_head_invariant(self): """Returns the expected number with `lslimit --head` and a filter.""" result = self.run_with_output( "lslimit", "--head", str(self.num_limit), self.track_tail_range ) assert result.count("\n") == self.num_limit def test_lslimit_tail_invariant(self): """Returns the expected number with `lslimit --tail` and a filter.""" result = self.run_with_output( "lslimit", "--tail", str(self.num_limit), self.track_head_range ) assert result.count("\n") == self.num_limit def test_prefix(self): """Returns the expected number with the query prefix.""" result = self.lib.items(self.num_limit_prefix) assert len(result) == self.num_limit def test_prefix_when_correctly_ordered(self): """Returns the expected number with the query prefix and filter when the prefix portion (correctly) appears last.""" correct_order = f"{self.track_tail_range} {self.num_limit_prefix}" result = self.lib.items(correct_order) assert len(result) == self.num_limit def test_prefix_when_incorrectly_ordred(self): """Returns no results with the query prefix and filter when the prefix portion (incorrectly) appears first.""" incorrect_order = f"{self.num_limit_prefix} {self.track_tail_range}" result = self.lib.items(incorrect_order) assert len(result) == 0 ================================================ FILE: test/plugins/test_listenbrainz.py ================================================ import pytest from beets.test.helper import ConfigMixin from beetsplug.listenbrainz import ListenBrainzPlugin class TestListenBrainzPlugin(ConfigMixin): @pytest.fixture(scope="class") def plugin(self) -> ListenBrainzPlugin: self.config["listenbrainz"]["token"] = "test_token" self.config["listenbrainz"]["username"] = "test_user" return ListenBrainzPlugin() @pytest.mark.parametrize( "search_response, expected_id", [([{"id": "id1"}], "id1"), ([], None)], ids=["found", "not_found"], ) def test_get_mb_recording_id( self, plugin, requests_mock, search_response, expected_id ): requests_mock.get( "/ws/2/recording", json={"recordings": search_response} ) track = {"track_metadata": {"track_name": "S", "release_name": "A"}} assert plugin.get_mb_recording_id(track) == expected_id def test_get_track_info(self, plugin, requests_mock): requests_mock.get( "/ws/2/recording/id1?inc=releases%2Bartist-credits", json={ "title": "T", "artist-credit": [], "releases": [{"title": "Al", "date": "2023-01"}], }, ) assert plugin.get_track_info([{"identifier": "id1"}]) == [ { "identifier": "id1", "title": "T", "artist": None, "album": "Al", "year": "2023", } ] ================================================ FILE: test/plugins/test_lyrics.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'lyrics' plugin.""" from __future__ import annotations import re import textwrap from functools import partial from http import HTTPStatus from typing import TYPE_CHECKING import pytest import requests from beets.library import Item from beets.test.helper import PluginMixin, TestHelper from beets.util.lyrics import Lyrics from beetsplug import lyrics from .lyrics_pages import lyrics_pages if TYPE_CHECKING: from pathlib import Path from .lyrics_pages import LyricsPage PHRASE_BY_TITLE = { "Lady Madonna": "friday night arrives without a suitcase", "Jazz'n'blues": "as i check my balance i kiss the screen", "Beets song": "via plugins, beets becomes a panacea", } @pytest.fixture(scope="module") def helper(): helper = TestHelper() helper.setup_beets() yield helper helper.teardown_beets() class TestLyricsUtils: @pytest.mark.parametrize( "artist, title", [ ("Various Artists", "Title"), ("Artist", ""), ("", "Title"), (" ", ""), ("", " "), ("", ""), ], ) def test_search_empty(self, artist, title): actual_pairs = lyrics.search_pairs(Item(artist=artist, title=title)) assert not list(actual_pairs) @pytest.mark.parametrize( "artist, artist_sort, expected_extra_artists", [ ("Alice ft. Bob", "", ["Alice"]), ("Alice feat Bob", "", ["Alice"]), ("Alice feat. Bob", "", ["Alice"]), ("Alice feats Bob", "", []), ("Alice featuring Bob", "", ["Alice"]), ("Alice & Bob", "", ["Alice"]), ("Alice and Bob", "", ["Alice"]), ("Alice", "", []), ("Alice", "Alice", []), ("Alice", "alice", []), ("Alice", "alice ", []), ("Alice", "Alice A", ["Alice A"]), ("CHVRCHΞS", "CHVRCHES", ["CHVRCHES"]), ("横山克", "Masaru Yokoyama", ["Masaru Yokoyama"]), ], ) def test_search_pairs_artists( self, artist, artist_sort, expected_extra_artists ): item = Item(artist=artist, artist_sort=artist_sort, title="song") actual_artists = [a for a, _ in lyrics.search_pairs(item)] # Make sure that the original artist name is still the first entry assert actual_artists == [artist, *expected_extra_artists] @pytest.mark.parametrize( "title, expected_extra_titles", [ ("1/2", []), ("1 / 2", ["1", "2"]), ("Song (live)", ["Song"]), ("Song (live) (new)", ["Song"]), ("Song (live (new))", ["Song"]), ("Song ft. B", ["Song"]), ("Song featuring B", ["Song"]), ("Song and B", []), ("Song: B", ["Song"]), ], ) def test_search_pairs_titles(self, title, expected_extra_titles): item = Item(title=title, artist="A") actual_titles = { t: None for _, tit in lyrics.search_pairs(item) for t in tit } assert list(actual_titles) == [title, *expected_extra_titles] @pytest.mark.parametrize( "text, expected", [ ("test", "test"), ("Mørdag", "mordag"), ("l'été c'est fait pour jouer", "l-ete-c-est-fait-pour-jouer"), ("\xe7afe au lait (boisson)", "cafe-au-lait-boisson"), ("Multiple spaces -- and symbols! -- merged", "multiple-spaces-and-symbols-merged"), # noqa: E501 ("\u200bno-width-space", "no-width-space"), ("El\u002dp", "el-p"), ("\u200bblackbear", "blackbear"), ("\u200d", ""), ("\u2010", ""), ], ) # fmt: skip def test_slug(self, text, expected): assert lyrics.slug(text) == expected class TestHtml: def test_scrape_strip_cruft(self): initial = """<!--lyrics below-->  one <br class='myclass'> two ! <br><br \\> <blink>four</blink>""" expected = "<!--lyrics below-->\none\ntwo !\n\n<blink>four</blink>" assert lyrics.Html.normalize_space(initial) == expected def test_scrape_merge_paragraphs(self): text = "one</p> <p class='myclass'>two</p><p>three" expected = "one\ntwo\n\nthree" assert lyrics.Html.merge_paragraphs(text) == expected class TestSearchBackend: @pytest.fixture def backend(self, dist_thresh): plugin = lyrics.LyricsPlugin() plugin.config.set({"dist_thresh": dist_thresh}) return lyrics.SearchBackend(plugin.config, plugin._log) @pytest.mark.parametrize( "dist_thresh, target_artist, artist, should_match", [ (0.11, "Target Artist", "Target Artist", True), (0.11, "Target Artist", "Target Artis", True), (0.11, "Target Artist", "Target Arti", False), (0.11, "Psychonaut", "Psychonaut (BEL)", True), (0.11, "beets song", "beats song", True), (0.10, "beets song", "beats song", False), ( 0.11, "Lucid Dreams (Forget Me)", "Lucid Dreams (Remix) ft. Lil Uzi Vert", False, ), ( 0.12, "Lucid Dreams (Forget Me)", "Lucid Dreams (Remix) ft. Lil Uzi Vert", True, ), ], ) def test_check_match(self, backend, target_artist, artist, should_match): result = lyrics.SearchResult(artist, "", "") assert backend.check_match(target_artist, "", result) == should_match @pytest.fixture(scope="module") def lyrics_root_dir(pytestconfig: pytest.Config): return pytestconfig.rootpath / "test" / "rsrc" / "lyrics" class LyricsPluginMixin(PluginMixin): plugin = "lyrics" @pytest.fixture def plugin_config(self): """Return lyrics configuration to test.""" return {} @pytest.fixture def lyrics_plugin(self, backend_name, plugin_config): """Set configuration and returns the plugin's instance.""" plugin_config["sources"] = [backend_name] self.config[self.plugin].set(plugin_config) return lyrics.LyricsPlugin() class TestLyricsPlugin(LyricsPluginMixin): @pytest.fixture def backend_name(self): """Return lyrics configuration to test.""" return "lrclib" @pytest.mark.parametrize( "request_kwargs, expected_log_match", [ ( {"status_code": HTTPStatus.BAD_GATEWAY}, r"LRCLib: Request error: 502", ), ({"text": "invalid"}, r"LRCLib: Could not decode.*JSON"), ], ) def test_error_handling( self, requests_mock, lyrics_plugin, caplog, request_kwargs, expected_log_match, ): """Errors are logged with the backend name.""" requests_mock.get(lyrics.LRCLib.SEARCH_URL, **request_kwargs) assert lyrics_plugin.get_lyrics("", "", "", 0.0) is None assert caplog.messages last_log = caplog.messages[-1] assert last_log assert re.search(expected_log_match, last_log, re.I) @pytest.mark.parametrize( "plugin_config, old_lyrics, found, expected", [ pytest.param( {}, "old", "new", "old", id="no_force_keeps_old", ), pytest.param( {"force": True}, "old", "new", "new", id="force_overwrites_with_new", ), pytest.param( {"force": True, "local": True}, "old", "new", "old", id="force_local_keeps_old", ), pytest.param( {"force": True, "fallback": None}, "old", None, "old", id="force_fallback_none_keeps_old", ), pytest.param( {"force": True, "fallback": ""}, "old", None, "", id="force_fallback_empty_uses_empty", ), pytest.param( {"force": True, "fallback": "default"}, "old", None, "default", id="force_fallback_default_uses_default", ), pytest.param( {"force": True, "synced": True}, "[00:00.00] old synced", "new plain", "[00:00.00] old synced", id="keep-existing-synced-lyrics", ), pytest.param( {"force": True, "synced": True}, "[00:00.00] old synced", "[00:00.00] new synced", "[00:00.00] new synced", id="replace-with-new-synced-lyrics", ), pytest.param( {"force": True, "synced": False}, "[00:00.00] old synced", "new plain", "new plain", id="replace-with-unsynced-lyrics-when-disabled", ), ], ) def test_overwrite_config( self, monkeypatch, helper, lyrics_plugin, old_lyrics, found, expected, ): monkeypatch.setattr( lyrics_plugin, "find_lyrics", lambda _: Lyrics(found) if found is not None else None, ) item = helper.create_item(id=1, lyrics=old_lyrics) lyrics_plugin.add_item_lyrics(item, False) assert item.lyrics == expected def test_set_additional_lyrics_info( self, monkeypatch, helper, lyrics_plugin, is_importable ): lyrics = Lyrics( "sing in the rain every hour of the day", "lrclib", url="https://lrclib.net/api/1", ) monkeypatch.setattr(lyrics_plugin, "find_lyrics", lambda _: lyrics) item = helper.add_item( id=1, lyrics="", lyrics_translation_language="EN" ) lyrics_plugin.add_item_lyrics(item, False) item = helper.lib.get_item(item.id) assert item.lyrics_url == lyrics.url assert item.lyrics_backend == lyrics.backend if is_importable("langdetect"): assert item.lyrics_language == "EN" else: with pytest.raises(AttributeError): item.lyrics_language # make sure translation language is cleared with pytest.raises(AttributeError): item.lyrics_translation_language class LyricsBackendTest(LyricsPluginMixin): @pytest.fixture def backend(self, lyrics_plugin): """Return a lyrics backend instance.""" return lyrics_plugin.backends[0] @pytest.fixture def lyrics_html(self, lyrics_root_dir, file_name): return (lyrics_root_dir / f"{file_name}.txt").read_text( encoding="utf-8" ) @pytest.mark.on_lyrics_update class TestLyricsSources(LyricsBackendTest): @pytest.fixture(scope="class") def plugin_config(self): return {"google_API_key": "test", "synced": True} @pytest.fixture( params=[pytest.param(lp, marks=lp.marks) for lp in lyrics_pages], ids=str, ) def lyrics_page(self, request): return request.param @pytest.fixture def backend_name(self, lyrics_page): return lyrics_page.backend @pytest.fixture(autouse=True) def _patch_google_search(self, requests_mock, lyrics_page): """Mock the Google Search API to return the lyrics page under test.""" requests_mock.real_http = True data = { "items": [ { "title": lyrics_page.url_title, "link": lyrics_page.url, "displayLink": lyrics_page.root_url, } ] } requests_mock.get(lyrics.Google.SEARCH_URL, json=data) def test_backend_source( self, monkeypatch, lyrics_plugin, lyrics_page: LyricsPage ): """Test parsed lyrics from each of the configured lyrics pages.""" monkeypatch.setattr( "beetsplug.lyrics.LyricsRequestHandler.create_session", lambda _: requests.Session(), ) assert lyrics_plugin.find_lyrics( Item( artist=lyrics_page.artist, title=lyrics_page.track_title, album="", length=186.0, ) ) == Lyrics( lyrics_page.lyrics, lyrics_page.backend, url=lyrics_page.url, language=lyrics_page.language, ) class TestGoogleLyrics(LyricsBackendTest): """Test scraping heuristics on a fake html page.""" @pytest.fixture(scope="class") def backend_name(self): return "google" @pytest.fixture def plugin_config(self): return {"google_API_key": "test"} @pytest.fixture(scope="class") def file_name(self): return "examplecom/beetssong" @pytest.fixture def search_item(self, url_title, url): return {"title": url_title, "link": url} @pytest.mark.parametrize("plugin_config", [{}]) def test_disabled_without_api_key(self, lyrics_plugin): assert not lyrics_plugin.backends def test_mocked_source_ok(self, backend, lyrics_html): """Test that lyrics of the mocked page are correctly scraped""" result = backend.scrape(lyrics_html).lower() assert result assert PHRASE_BY_TITLE["Beets song"] in result @pytest.mark.parametrize( "url_title, expected_artist, expected_title", [ ("Artist - beets song Lyrics", "Artist", "beets song"), ("www.azlyrics.com | Beats song by Artist", "Artist", "Beats song"), ("lyric.com | seets bong lyrics by Artist", "Artist", "seets bong"), ("foo", "", "foo"), ("Artist - Beets Song lyrics | AZLyrics", "Artist", "Beets Song"), ("Letra de Artist - Beets Song", "Artist", "Beets Song"), ("Letra de Artist - Beets ...", "Artist", "Beets"), ("Artist Beets Song", "Artist", "Beets Song"), ("BeetsSong - Artist", "Artist", "BeetsSong"), ("Artist - BeetsSong", "Artist", "BeetsSong"), ("Beets Song", "", "Beets Song"), ("Beets Song Artist", "Artist", "Beets Song"), ( "BeetsSong (feat. Other & Another) - Artist", "Artist", "BeetsSong (feat. Other & Another)", ), ( ( "Beets song lyrics by Artist - original song full text. " "Official Beets song lyrics, 2024 version | LyricsMode.com" ), "Artist", "Beets song", ), ], ) @pytest.mark.parametrize("url", ["http://doesntmatter.com"]) def test_make_search_result( self, backend, search_item, expected_artist, expected_title ): result = backend.make_search_result("Artist", "Beets song", search_item) assert result.artist == expected_artist assert result.title == expected_title class TestGeniusLyrics(LyricsBackendTest): @pytest.fixture(scope="class") def backend_name(self): return "genius" @pytest.mark.parametrize( "file_name, expected_line_count", [ ("geniuscom/2pacalleyezonmelyrics", 131), ("geniuscom/Ttngchinchillalyrics", 29), ("geniuscom/sample", 0), # see https://github.com/beetbox/beets/issues/3535 ], ) # fmt: skip def test_scrape(self, backend, lyrics_html, expected_line_count): result = backend.scrape(lyrics_html) or "" assert len(result.splitlines()) == expected_line_count class TestTekstowoLyrics(LyricsBackendTest): @pytest.fixture(scope="class") def backend_name(self): return "tekstowo" @pytest.mark.parametrize( "file_name, expecting_lyrics", [ ("tekstowopl/piosenka24kgoldncityofangels1", True), ( "tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement", False, ), ], ) def test_scrape(self, backend, lyrics_html, expecting_lyrics): assert bool(backend.scrape(lyrics_html)) == expecting_lyrics LYRICS_DURATION = 950 def lyrics_match(**overrides): return { "id": 1, "instrumental": False, "duration": LYRICS_DURATION, "syncedLyrics": "[00:00.00] synced", "plainLyrics": "plain", **overrides, } class TestLRCLibLyrics(LyricsBackendTest): ITEM_DURATION = 999 SYNCED = "[00:00.00] synced" @pytest.fixture(scope="class") def backend_name(self): return "lrclib" @pytest.fixture def fetch_lyrics(self, backend, requests_mock, response_data): requests_mock.get(backend.GET_URL, status_code=HTTPStatus.NOT_FOUND) requests_mock.get(backend.SEARCH_URL, json=response_data) return partial(backend.fetch, "la", "la", "la", self.ITEM_DURATION) @pytest.mark.parametrize("response_data", [[lyrics_match()]]) @pytest.mark.parametrize( "plugin_config, expected_lyrics", [ pytest.param({"synced": True}, SYNCED, id="pick-synced"), pytest.param({"synced": False}, "plain", id="pick-plain"), ], ) def test_synced_config_option( self, backend_name, fetch_lyrics, expected_lyrics ): lyrics = fetch_lyrics() assert lyrics assert lyrics.text == expected_lyrics assert lyrics.backend == backend_name @pytest.mark.parametrize( "response_data, expected_lyrics", [ pytest.param([], None, id="handle non-matching lyrics"), pytest.param( [lyrics_match()], SYNCED, id="synced when available", ), pytest.param( [lyrics_match(duration=1)], None, id="none: duration too short", ), pytest.param( [lyrics_match(instrumental=True)], "[Instrumental]", id="instrumental track", ), pytest.param( [lyrics_match(syncedLyrics=None)], "plain", id="plain by default", ), pytest.param( [ lyrics_match( duration=ITEM_DURATION, syncedLyrics=None, plainLyrics="plain with closer duration", ), lyrics_match(syncedLyrics=SYNCED, plainLyrics="plain 2"), ], SYNCED, id="prefer synced lyrics even if plain duration is closer", ), pytest.param( [ lyrics_match( duration=ITEM_DURATION, syncedLyrics=None, plainLyrics="valid plain", ), lyrics_match( duration=1, syncedLyrics="synced with invalid duration", ), ], "valid plain", id="ignore synced with invalid duration", ), pytest.param( [ lyrics_match( duration=59, syncedLyrics="[01:00.00] invalid synced" ) ], None, id="ignore synced with a timestamp longer than duration", ), pytest.param( [lyrics_match(syncedLyrics=None), lyrics_match()], SYNCED, id="prefer match with synced lyrics", ), ], ) @pytest.mark.parametrize("plugin_config", [{"synced": True}]) def test_fetch_lyrics(self, fetch_lyrics, expected_lyrics): lyrics = fetch_lyrics() if expected_lyrics is None: assert not lyrics else: assert lyrics assert lyrics.text == expected_lyrics @pytest.mark.requires_import("langdetect") class TestTranslation: @pytest.fixture(autouse=True) def _patch_bing(self, requests_mock): def callback(request, _): if b"Refrain" in request.body: translations = ( "" " | [Refrain : Doja Cat]" " | Difficile pour moi de te laisser partir (Te laisser partir, te laisser partir)" # noqa: E501 " | Mon corps ne me laissait pas le cacher (Cachez-le)" " | [Chorus]" " | Quoi qu’il arrive, je ne plierais pas (Ne plierait pas, ne plierais pas)" # noqa: E501 " | Chevauchant à travers le tonnerre, la foudre" ) elif b"00:00.00" in request.body: translations = ( "" " | [00:00.00] Quelques paroles synchronisées" " | [00:01.00] Quelques paroles plus synchronisées" ) else: translations = ( "" " | Quelques paroles synchronisées" " | Quelques paroles plus synchronisées" ) return [ { "detectedLanguage": {"language": "en", "score": 1.0}, "translations": [{"text": translations, "to": "fr"}], } ] requests_mock.post(lyrics.Translator.TRANSLATE_URL, json=callback) @pytest.mark.parametrize( "new_lyrics, old_lyrics, expected", [ pytest.param( """ [Refrain: Doja Cat] Hard for me to let you go (Let you go, let you go) My body wouldn't let me hide it (Hide it) [Chorus] No matter what, I wouldn't fold (Wouldn't fold, wouldn't fold) Ridin' through the thunder, lightnin'""", Lyrics(""), """ [Refrain: Doja Cat] / [Refrain : Doja Cat] Hard for me to let you go (Let you go, let you go) / Difficile pour moi de te laisser partir (Te laisser partir, te laisser partir) My body wouldn't let me hide it (Hide it) / Mon corps ne me laissait pas le cacher (Cachez-le) [Chorus] No matter what, I wouldn't fold (Wouldn't fold, wouldn't fold) / Quoi qu’il arrive, je ne plierais pas (Ne plierait pas, ne plierais pas) Ridin' through the thunder, lightnin' / Chevauchant à travers le tonnerre, la foudre""", # noqa: E501 id="plain", ), pytest.param( """ [00:00.00] Some synced lyrics [00:00.50] [00:01.00] Some more synced lyrics """, Lyrics(""), """ [00:00.00] Some synced lyrics / Quelques paroles synchronisées [00:00.50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées""", # noqa: E501 id="synced", ), pytest.param( "Quelques paroles", Lyrics(""), "Quelques paroles", id="already in the target language", ), pytest.param( "Some lyrics", Lyrics( "Some lyrics / Some translation", language="EN", translation_language="FR", ), "Some lyrics / Some translation", id="already translated", ), ], ) def test_translate(self, new_lyrics, old_lyrics, expected): plugin = lyrics.LyricsPlugin() bing = lyrics.Translator(plugin._log, "123", "FR", ["EN"]) assert bing.translate( Lyrics(textwrap.dedent(new_lyrics)), old_lyrics ).full_text == textwrap.dedent(expected) class TestRestFiles: @pytest.fixture def rest_dir(self, tmp_path): return tmp_path @pytest.fixture def rest_files(self, rest_dir): return lyrics.RestFiles(rest_dir) def test_write(self, rest_dir: Path, rest_files): items = [ Item(albumartist=aa, album=a, title=t, lyrics=lyr) for aa, a, t, lyr in [ ("Artist One", "Album One", "Song One", "Lyrics One"), ("Artist One", "Album One", "Song Two", "Lyrics Two"), ("Artist Two", "Album Two", "Song Three", "Lyrics Three"), ] ] rest_files.write(items) assert (rest_dir / "index.rst").exists() assert (rest_dir / "conf.py").exists() artist_one_file = rest_dir / "artists" / "artist-one.rst" artist_two_file = rest_dir / "artists" / "artist-two.rst" assert artist_one_file.exists() assert artist_two_file.exists() c = artist_one_file.read_text() assert ( c.index("Artist One") < c.index("Album One") < c.index("Song One") < c.index("Lyrics One") < c.index("Song Two") < c.index("Lyrics Two") ) c = artist_two_file.read_text() assert ( c.index("Artist Two") < c.index("Album Two") < c.index("Song Three") < c.index("Lyrics Three") ) ================================================ FILE: test/plugins/test_mbcollection.py ================================================ import re import uuid from contextlib import nullcontext as does_not_raise import pytest from beets.library import Album from beets.test.helper import PluginMixin, TestHelper from beets.ui import UserError from beetsplug import mbcollection class TestMbCollectionPlugin(PluginMixin, TestHelper): """Tests for the MusicBrainzCollectionPlugin class methods.""" plugin = "mbcollection" COLLECTION_ID = str(uuid.uuid4()) @pytest.fixture(autouse=True) def setup_config(self): self.config["musicbrainz"]["user"] = "testuser" self.config["musicbrainz"]["pass"] = "testpass" self.config["mbcollection"]["collection"] = self.COLLECTION_ID @pytest.fixture(autouse=True) def helper(self): self.setup_beets() yield self self.teardown_beets() @pytest.mark.parametrize( "user_collections,expectation", [ ( [], pytest.raises( UserError, match=r"no collections exist for user" ), ), ( [{"id": "c1", "entity-type": "event"}], pytest.raises(UserError, match=r"No release collection found."), ), ( [{"id": "c1", "entity-type": "release"}], pytest.raises(UserError, match=r"invalid collection ID"), ), ( [{"id": COLLECTION_ID, "entity-type": "release"}], does_not_raise(), ), ], ids=["no collections", "no release collections", "invalid ID", "valid"], ) def test_get_collection_validation( self, requests_mock, user_collections, expectation ): requests_mock.get( "/ws/2/collection", json={"collections": user_collections} ) with expectation: mbcollection.MusicBrainzCollectionPlugin().collection def test_mbupdate(self, helper, requests_mock, monkeypatch): """Verify mbupdate sync of a MusicBrainz collection with the library. This test ensures that the command: - fetches collection releases using paginated requests, - submits releases that exist locally but are missing from the remote collection - and removes releases from the remote collection that are not in the local library. Small chunk sizes are forced to exercise pagination and batching logic. """ for mb_albumid in [ # already present in remote collection "in_collection1", "in_collection2", # two new albums not in remote collection "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", ]: helper.lib.add(Album(mb_albumid=mb_albumid)) # The relevant collection requests_mock.get( "/ws/2/collection", json={ "collections": [ { "id": self.COLLECTION_ID, "entity-type": "release", "release-count": 3, } ] }, ) collection_releases = f"/ws/2/collection/{self.COLLECTION_ID}/releases" # Force small fetch chunk to require multiple paged requests. monkeypatch.setattr( "beetsplug.mbcollection.MBCollection.FETCH_CHUNK_SIZE", 2 ) # 3 releases are fetched in two pages. requests_mock.get( re.compile(rf".*{collection_releases}\b.*&offset=0.*"), json={ "releases": [{"id": "in_collection1"}, {"id": "not_in_library"}] }, ) requests_mock.get( re.compile(rf".*{collection_releases}\b.*&offset=2.*"), json={"releases": [{"id": "in_collection2"}]}, ) # Force small submission chunk monkeypatch.setattr( "beetsplug.mbcollection.MBCollection.SUBMISSION_CHUNK_SIZE", 1 ) # so that releases are added using two requests requests_mock.put( re.compile( rf".*{collection_releases}/00000000-0000-0000-0000-000000000001" ) ) requests_mock.put( re.compile( rf".*{collection_releases}/00000000-0000-0000-0000-000000000002" ) ) # and finally, one release is removed requests_mock.delete( re.compile(rf".*{collection_releases}/not_in_library") ) helper.run_command("mbupdate", "--remove") assert requests_mock.call_count == 6 ================================================ FILE: test/plugins/test_mbpseudo.py ================================================ from __future__ import annotations import json from copy import deepcopy from typing import TYPE_CHECKING import pytest from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginMixin from beetsplug.mbpseudo import ( _STATUS_PSEUDO, MusicBrainzPseudoReleasePlugin, PseudoAlbumInfo, ) if TYPE_CHECKING: import pathlib from beetsplug._typing import JSONDict @pytest.fixture(scope="module") def rsrc_dir(pytestconfig: pytest.Config): return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" @pytest.fixture def official_release(rsrc_dir: pathlib.Path) -> JSONDict: info_json = (rsrc_dir / "official_release.json").read_text(encoding="utf-8") return json.loads(info_json) @pytest.fixture def pseudo_release(rsrc_dir: pathlib.Path) -> JSONDict: info_json = (rsrc_dir / "pseudo_release.json").read_text(encoding="utf-8") return json.loads(info_json) @pytest.fixture def official_release_info() -> AlbumInfo: return AlbumInfo( tracks=[TrackInfo(title="百花繚乱")], album_id="official", album="百花繚乱", ) @pytest.fixture def pseudo_release_info() -> AlbumInfo: return AlbumInfo( tracks=[TrackInfo(title="In Bloom")], album_id="pseudo", album="In Bloom", ) @pytest.mark.usefixtures("config") class TestPseudoAlbumInfo: def test_album_id_always_from_pseudo( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album_id == "pseudo" def test_get_attr_from_pseudo( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo(pseudo_release_info, official_release_info) assert info.album == "In Bloom" def test_get_attr_from_official( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album == info.get_official_release().album def test_determine_best_ref( self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo( pseudo_release_info, official_release_info, data_source="test" ) item = Item(title="百花繚乱") assert info.determine_best_ref([item]) == "official" info.use_pseudo_as_ref() assert info.data_source == "test" class TestMBPseudoMixin(PluginMixin): plugin = "mbpseudo" @pytest.fixture(autouse=True) def patch_get_release(self, monkeypatch, pseudo_release: JSONDict): monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda _, album_id: deepcopy( {pseudo_release["id"]: pseudo_release}[album_id] ), ) @pytest.fixture(scope="class") def plugin_config(self): return {"scripts": ["Latn", "Dummy"]} @pytest.fixture def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin: self.config[self.plugin].set(plugin_config) return MusicBrainzPseudoReleasePlugin() class TestMBPseudoPlugin(TestMBPseudoMixin): def test_scripts_init( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin ): assert mbpseudo_plugin._scripts == ["Latn", "Dummy"] @pytest.mark.parametrize( "album_id", [ "a5ce1d11-2e32-45a4-b37f-c1589d46b103", "-5ce1d11-2e32-45a4-b37f-c1589d46b103", ], ) def test_extract_id_uses_music_brainz_pattern( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, album_id: str, ): if album_id.startswith("-"): assert mbpseudo_plugin._extract_id(album_id) is None else: assert mbpseudo_plugin._extract_id(album_id) == album_id def test_album_info_for_pseudo_release( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, pseudo_release: JSONDict, ): album_info = mbpseudo_plugin.album_info(pseudo_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info.albumstatus == _STATUS_PSEUDO @pytest.mark.parametrize( "json_key", [ "type", "direction", "release", ], ) def test_interception_skip_when_rel_values_dont_match( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, json_key: str, ): del official_release["release-relations"][0][json_key] album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception_skip_when_script_doesnt_match( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, ): official_release["release-relations"][0]["release"][ "text-representation" ]["script"] = "Null" album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, ): album_info = mbpseudo_plugin.album_info(official_release) assert isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" def test_final_adjustment_skip( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, ): match = AlbumMatch( distance=Distance(), info=AlbumInfo(tracks=[], data_source="mb"), mapping={}, extra_items=[], extra_tracks=[], ) mbpseudo_plugin._adjust_final_album_match(match) assert match.info.data_source == "mb" def test_final_adjustment( self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo, ): pseudo_album_info = PseudoAlbumInfo( pseudo_release=pseudo_release_info, official_release=official_release_info, data_source=mbpseudo_plugin.data_source, ) pseudo_album_info.use_official_as_ref() item = Item() item["title"] = "百花繚乱" match = AlbumMatch( distance=Distance(), info=pseudo_album_info, mapping={item: pseudo_album_info.tracks[0]}, extra_items=[], extra_tracks=[], ) mbpseudo_plugin._adjust_final_album_match(match) assert match.info.data_source == "MusicBrainz" assert match.info.album_id == "pseudo" assert match.info.album == "In Bloom" class TestMBPseudoPluginCustomTagsOnly(TestMBPseudoMixin): @pytest.fixture(scope="class") def plugin_config(self): return {"scripts": ["Latn", "Dummy"], "custom_tags_only": True} def test_custom_tags( self, config, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, ): config["import"]["languages"] = ["en", "jp"] album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info["album_transl"] == "In Bloom" assert album_info["album_artist_transl"] == "Lilas Ikuta" assert album_info.tracks[0]["title_transl"] == "In Bloom" assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" def test_custom_tags_with_import_languages( self, config, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, official_release: JSONDict, ): config["import"]["languages"] = [] album_info = mbpseudo_plugin.album_info(official_release) assert not isinstance(album_info, PseudoAlbumInfo) assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info["album_transl"] == "In Bloom" assert album_info["album_artist_transl"] == "Lilas Ikuta" assert album_info.tracks[0]["title_transl"] == "In Bloom" assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" ================================================ FILE: test/plugins/test_mbsubmit.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from beets.test.helper import ( AutotagImportTestCase, PluginMixin, TerminalImportMixin, ) class MBSubmitPluginTest( PluginMixin, TerminalImportMixin, AutotagImportTestCase ): plugin = "mbsubmit" def setUp(self): super().setUp() self.prepare_album_for_import(2) self.setup_importer() def test_print_tracks_output(self): """Test the output of the "print tracks" choice.""" self.io.addinput("p") self.io.addinput("s") # Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = ( "Open files with Picard? " "01. Tag Track 1 - Tag Artist (0:01)\n" "02. Tag Track 2 - Tag Artist (0:01)" ) assert tracklist in self.io.getoutput() def test_print_tracks_output_as_tracks(self): """Test the output of the "print tracks" choice, as singletons.""" self.io.addinput("t") self.io.addinput("s") self.io.addinput("p") self.io.addinput("s") # as Tracks; Skip; Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = ( "Open files with Picard? 02. Tag Track 2 - Tag Artist (0:01)" ) assert tracklist in self.io.getoutput() ================================================ FILE: test/plugins/test_mbsync.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from unittest.mock import Mock, patch from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginTestCase, capture_log class MbsyncCliTest(PluginTestCase): plugin = "mbsync" @patch( "beets.metadata_plugins.album_for_id", Mock( side_effect=lambda *_: AlbumInfo( album_id="album id", album="new album", tracks=[TrackInfo(track_id="track id", title="new title")], ) ), ) @patch( "beets.metadata_plugins.track_for_id", Mock( side_effect=lambda *_: TrackInfo( track_id="singleton id", title="new title" ) ), ) def test_update_library(self): album_item = Item( album="old album", mb_albumid="album id", mb_trackid="track id", data_source="data_source", ) self.lib.add_album([album_item]) singleton = Item( title="old title", mb_trackid="singleton id", data_source="data_source", ) self.lib.add(singleton) self.run_command("mbsync") singleton.load() assert singleton.title == "new title" album_item.load() assert album_item.title == "new title" assert album_item.mb_trackid == "track id" assert album_item.get_album().album == "new album" def test_custom_format(self): for item in [ Item(artist="albumartist", album="no id"), Item( artist="albumartist", album="invalid id", mb_albumid="a1b2c3d4", ), ]: self.lib.add_album([item]) for item in [ Item(artist="artist", title="no id"), Item(artist="artist", title="invalid id", mb_trackid="a1b2c3d4"), ]: self.lib.add(item) with capture_log("beets.mbsync") as logs: self.run_command("mbsync", "-f", "'%if{$album,$album,$title}'") assert "mbsync: Skipping album with no mb_albumid: 'no id'" in logs assert "mbsync: Skipping singleton with no mb_trackid: 'no id'" in logs ================================================ FILE: test/plugins/test_missing.py ================================================ """Tests for the `missing` plugin.""" import re import uuid from unittest.mock import patch import pytest from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Album, Item from beets.test.helper import IOMixin, PluginMixin, TestHelper @pytest.fixture def helper(request): helper = TestHelper() helper.setup_beets() request.instance.lib = helper.lib yield helper.teardown_beets() @pytest.mark.usefixtures("helper") class TestMissingAlbums(IOMixin, PluginMixin): """Tests for missing albums functionality.""" plugin = "missing" @pytest.mark.parametrize( "release_from_mb,expected_output", [ pytest.param( {"id": "other", "title": "Other Album"}, "Artist - Other Album\n", id="missing", ), pytest.param( {"id": "release_group_in_lib", "title": "Album"}, "", id="not missing", ), ], ) def test_missing_artist_albums( self, requests_mock, release_from_mb, expected_output ): artist_mbid = str(uuid.uuid4()) self.lib.add( Album( album="Album", albumartist="Artist", mb_albumartistid=artist_mbid, mb_albumid="album", mb_releasegroupid="release_group_in_lib", ) ) requests_mock.get( re.compile( rf"/ws/2/release-group\?artist={artist_mbid}&.*type=album" ), json={"release-groups": [release_from_mb]}, ) with self.configure_plugin({}): assert self.run_with_output("missing", "--album") == expected_output def test_release_types_filters_results(self, requests_mock): """Test --release-types filters to only show specified type.""" artist_mbid = str(uuid.uuid4()) self.lib.add( Album( album="album", albumartist="artist", mb_albumartistid=artist_mbid, mb_albumid="album", mb_releasegroupid="album_id", ) ) requests_mock.get( re.compile(r"/ws/2/release-group.*type=compilation"), json={ "release-groups": [ {"id": "compilation_id", "title": "compilation"} ] }, ) with self.configure_plugin({}): output = self.run_with_output( "missing", "-a", "--release-types", "compilation" ) assert "artist - compilation" in output def test_release_types_comma_separated(self, requests_mock): """Test --release-types with comma-separated values.""" artist_mbid = str(uuid.uuid4()) self.lib.add( Album( album="album", albumartist="artist", mb_albumartistid=artist_mbid, mb_albumid="album", mb_releasegroupid="album_id", ) ) requests_mock.get( re.compile(r"/ws/2/release-group.*type=compilation%7Calbum"), json={ "release-groups": [ {"id": "album2_id", "title": "title 2"}, {"id": "compilation_id", "title": "compilation"}, ] }, ) with self.configure_plugin({}): output = self.run_with_output( "missing", "-a", "--release-types", "compilation,album", ) assert "artist - compilation" in output assert "artist - title 2" in output def test_empty_release_types_config_sends_empty_type(self, requests_mock): """Test that release_types: [] in config sends type="" to the API.""" artist_mbid = str(uuid.uuid4()) self.lib.add( Album( album="album", albumartist="artist", mb_albumartistid=artist_mbid, mb_albumid="album", mb_releasegroupid="album_id", ) ) adapter = requests_mock.get( re.compile(r"/ws/2/release-group"), json={"release-groups": []}, ) with self.configure_plugin({"release_types": []}): self.run_with_output("missing", "-a") assert adapter.last_request.qs["type"] == [""] def test_missing_albums_total(self, requests_mock): """Test -t flag with --album shows total count of missing albums.""" artist_mbid = str(uuid.uuid4()) self.lib.add( Album( album="album", albumartist="artist", mb_albumartistid=artist_mbid, mb_albumid="album", mb_releasegroupid="album_id", ) ) requests_mock.get( re.compile( rf"/ws/2/release-group\?artist={artist_mbid}&.*type=album" ), json={ "release-groups": [ {"id": "album_id", "title": "album"}, {"id": "other_id", "title": "other"}, ] }, ) with self.configure_plugin({}): output = self.run_with_output("missing", "-a", "-t") assert output == "1\n" @pytest.mark.usefixtures("helper") class TestMissingTracks(IOMixin, PluginMixin): """Tests for missing tracks functionality.""" plugin = "missing" @pytest.mark.parametrize( "total,count,expected", [ (True, False, "1\n"), (False, True, "artist - album: 1"), ], ) @patch("beets.metadata_plugins.album_for_id") def test_missing_tracks(self, album_for_id, total, count, expected): """Test getting missing tracks works with expected output.""" artist_mbid = str(uuid.uuid4()) album_items = [ Item( album="album", mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", mb_releasegroupid="album_id", mb_trackid="track_1", mb_albumartistid=artist_mbid, albumartist="artist", tracktotal=3, ), Item( album="album", mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", mb_releasegroupid="album_id", mb_albumartistid=artist_mbid, albumartist="artist", tracktotal=3, ), Item( album="album", mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", mb_releasegroupid="album_id", mb_trackid="track_3", mb_albumartistid=artist_mbid, albumartist="artist", tracktotal=3, ), ] self.lib.add_album(album_items[:2]) album_for_id.return_value = AlbumInfo( album_id="album_id", album="album", tracks=[ TrackInfo(track_id=item.mb_trackid) for item in album_items ], ) command = ["missing"] if total: command.append("-t") if count: command.append("-c") with self.configure_plugin({}): assert expected in self.run_with_output(*command) ================================================ FILE: test/plugins/test_mpdstats.py ================================================ # This file is part of beets. # Copyright 2016 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from typing import Any, ClassVar from unittest.mock import ANY, Mock, call, patch from beets import util from beets.library import Item from beets.test.helper import PluginTestCase from beetsplug.mpdstats import MPDStats class MPDStatsTest(PluginTestCase): plugin = "mpdstats" def test_update_rating(self): item = Item(title="title", path="", id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) assert not mpdstats.update_rating(item, True) assert not mpdstats.update_rating(None, True) def test_get_item(self): item_path = util.normpath("/foo/bar.flac") item = Item(title="title", path=item_path, id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) assert str(mpdstats.get_item(item_path)) == str(item) assert mpdstats.get_item("/some/non-existing/path") is None assert "item not found:" in log.info.call_args[0][0] STATUSES: ClassVar[list[dict[str, Any]]] = [ {"state": "some-unknown-one"}, {"state": "pause"}, {"state": "play", "songid": 1, "time": "0:1"}, {"state": "stop"}, ] EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt] item_path = util.normpath("/foo/bar.flac") songid = 1 @patch( "beetsplug.mpdstats.MPDClientWrapper", return_value=Mock( **{ "events.side_effect": EVENTS, "status.side_effect": STATUSES, "currentsong.return_value": (item_path, songid), } ), ) def test_run_mpdstats(self, mpd_mock): item = Item(title="title", path=self.item_path, id=1) item.add(self.lib) log = Mock() try: MPDStats(self.lib, log).run() except KeyboardInterrupt: pass log.debug.assert_has_calls([call('unhandled status "{}"', ANY)]) log.info.assert_has_calls( [call("pause"), call("playing {}", ANY), call("stop")] ) ================================================ FILE: test/plugins/test_musicbrainz.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for MusicBrainz API wrapper.""" import unittest import uuid from typing import Any, ClassVar from unittest import mock import pytest import requests from beets import config from beets.library import Item from beets.test.helper import BeetsTestCase, PluginMixin from beetsplug import musicbrainz def make_alias(suffix: str, locale: str, primary: bool = False): alias: dict[str, Any] = { "name": f"ALIAS{suffix}", "locale": locale, "sort-name": f"ALIASSORT{suffix}", } if primary: alias["primary"] = True return alias class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() self.mb = musicbrainz.MusicBrainzPlugin() self.config["match"]["preferred"]["countries"] = ["US"] class MBAlbumInfoTest(MusicBrainzTestCase): def _make_release( self, date_str="2009", tracks=None, track_length=None, track_artist=False, multi_artist_credit=False, data_tracks=None, medium_format="FORMAT", ): release = { "title": "ALBUM TITLE", "id": "ALBUM ID", "asin": "ALBUM ASIN", "disambiguation": "R_DISAMBIGUATION", "release-group": { "primary-type": "Album", "first-release-date": date_str, "id": "RELEASE GROUP ID", "disambiguation": "RG_DISAMBIGUATION", "title": "RELEASE GROUP TITLE", }, "artist-credit": [ { "artist": { "name": "ARTIST NAME", "id": "ARTIST ID", "sort-name": "ARTIST SORT NAME", }, "name": "ARTIST CREDIT", } ], "date": "3001", "media": [], "genres": [{"count": 1, "name": "GENRE"}], "tags": [{"count": 1, "name": "TAG"}], "label-info": [ { "catalog-number": "CATALOG NUMBER", "label": {"name": "LABEL NAME"}, } ], "text-representation": { "script": "SCRIPT", "language": "LANGUAGE", }, "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", "release-events": [{"area": None, "date": "2021-03-26"}], } if multi_artist_credit: release["artist-credit"][0]["joinphrase"] = " & " release["artist-credit"].append( { "artist": { "name": "ARTIST 2 NAME", "id": "ARTIST 2 ID", "sort-name": "ARTIST 2 SORT NAME", }, "name": "ARTIST MULTI CREDIT", } ) i = 0 track_list = [] if tracks: for recording in tracks: i += 1 track = { "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", } if track_length: # Track lengths are distinct from recording lengths. track["length"] = track_length if track_artist: # Similarly, track artists can differ from recording # artists. track["artist-credit"] = [ { "artist": { "name": "TRACK ARTIST NAME", "id": "TRACK ARTIST ID", "sort-name": "TRACK ARTIST SORT NAME", }, "name": "TRACK ARTIST CREDIT", } ] if multi_artist_credit: track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { "name": "TRACK ARTIST 2 NAME", "id": "TRACK ARTIST 2 ID", "sort-name": "TRACK ARTIST 2 SORT NAME", }, "name": "TRACK ARTIST 2 CREDIT", } ) track_list.append(track) data_track_list = [] if data_tracks: for recording in data_tracks: i += 1 data_track = { "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", } data_track_list.append(data_track) release["media"].append( { "position": "1", "tracks": track_list, "data-tracks": data_track_list, "format": medium_format, "title": "MEDIUM TITLE", } ) return release def _make_track( self, title, tr_id, duration, artist=False, video=False, disambiguation=None, remixer=False, multi_artist_credit=False, aliases=None, ): track = { "title": title, "id": tr_id, } if duration is not None: track["length"] = duration if artist: track["artist-credit"] = [ { "artist": { "name": "RECORDING ARTIST NAME", "id": "RECORDING ARTIST ID", "sort-name": "RECORDING ARTIST SORT NAME", }, "name": "RECORDING ARTIST CREDIT", } ] if multi_artist_credit: track["artist-credit"][0]["joinphrase"] = " & " track["artist-credit"].append( { "artist": { "name": "RECORDING ARTIST 2 NAME", "id": "RECORDING ARTIST 2 ID", "sort-name": "RECORDING ARTIST 2 SORT NAME", }, "name": "RECORDING ARTIST 2 CREDIT", } ) if remixer: track["artist-relations"] = [ { "type": "remixer", "type-id": "RELATION TYPE ID", "direction": "RECORDING RELATION DIRECTION", "artist": { "id": "RECORDING REMIXER ARTIST ID", "type": "RECORDING REMIXER ARTIST TYPE", "name": "RECORDING REMIXER ARTIST NAME", "sort-name": "RECORDING REMIXER ARTIST SORT NAME", }, } ] if video: track["video"] = True if disambiguation: track["disambiguation"] = disambiguation if aliases is not None: track["aliases"] = aliases return track def test_parse_release_title(self): release = self._make_release(None) release["aliases"] = [ make_alias(suffix="en", locale="en", primary=True), ] # test no alias config["import"]["languages"] = [] d = self.mb.album_info(release) assert d.album == "ALBUM TITLE" # test en primary config["import"]["languages"] = ["en"] d = self.mb.album_info(release) assert d.album == "ALIASen" def test_parse_release_with_year(self): release = self._make_release("1984") d = self.mb.album_info(release) assert d.album == "ALBUM TITLE" assert d.album_id == "ALBUM ID" assert d.artist == "ARTIST NAME" assert d.artist_id == "ARTIST ID" assert d.original_year == 1984 assert d.year == 3001 assert d.artist_credit == "ARTIST CREDIT" def test_parse_release_type(self): release = self._make_release("1984") d = self.mb.album_info(release) assert d.albumtype == "album" def test_parse_release_full_date(self): release = self._make_release("1987-03-31") d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 assert d.original_day == 31 def test_parse_tracks(self): tracks = [ self._make_track( "TITLE ONE", "ID ONE", 100.0 * 1000.0, aliases=[make_alias(suffix="ONEen", locale="en", primary=True)], ), self._make_track( "TITLE TWO", "ID TWO", 200.0 * 1000.0, aliases=[make_alias(suffix="TWOen", locale="en", primary=True)], ), ] release = self._make_release(tracks=tracks) # Preference over recording data release["media"][0]["tracks"][1]["title"] = "TRACK TITLE TWO" # test no alias config["import"]["languages"] = [] d = self.mb.album_info(release) t = d.tracks assert len(t) == 2 assert t[0].title == "TITLE ONE" assert t[0].track_id == "ID ONE" assert t[0].length == 100.0 assert t[1].title == "TRACK TITLE TWO" assert t[1].track_id == "ID TWO" assert t[1].length == 200.0 # test en primary config["import"]["languages"] = ["en"] d = self.mb.album_info(release) t = d.tracks assert len(t) == 2 assert t[0].title == "ALIASONEen" assert t[0].track_id == "ID ONE" assert t[0].length == 100.0 assert t[1].title == "ALIASTWOen" assert t[1].track_id == "ID TWO" assert t[1].length == 200.0 def test_parse_track_indices(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) t = d.tracks assert t[0].medium_index == 1 assert t[0].index == 1 assert t[1].medium_index == 2 assert t[1].index == 2 def test_parse_medium_numbers_single_medium(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) assert d.mediums == 1 t = d.tracks assert t[0].medium == 1 assert t[1].medium == 1 def test_parse_medium_numbers_two_mediums(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=[tracks[0]]) second_track_list = [ { "id": "RELEASE TRACK ID 2", "recording": tracks[1], "position": "1", "number": "A1", } ] release["media"].append( { "position": "2", "tracks": second_track_list, } ) d = self.mb.album_info(release) assert d.mediums == 2 t = d.tracks assert t[0].medium == 1 assert t[0].medium_index == 1 assert t[0].index == 1 assert t[1].medium == 2 assert t[1].medium_index == 1 assert t[1].index == 2 def test_parse_release_year_month_only(self): release = self._make_release("1987-03") d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 def test_no_durations(self): tracks = [self._make_track("TITLE", "ID", None)] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) assert d.tracks[0].length is None def test_track_length_overrides_recording_length(self): tracks = [self._make_track("TITLE", "ID", 1.0 * 1000.0)] release = self._make_release(tracks=tracks, track_length=2.0 * 1000.0) d = self.mb.album_info(release) assert d.tracks[0].length == 2.0 def test_no_release_date(self): release = self._make_release(None) d = self.mb.album_info(release) assert not d.original_year assert not d.original_month assert not d.original_day def test_various_artists_defaults_false(self): release = self._make_release(None) d = self.mb.album_info(release) assert not d.va def test_detect_various_artists(self): release = self._make_release(None) release["artist-credit"][0]["artist"]["id"] = ( musicbrainz.VARIOUS_ARTISTS_ID ) d = self.mb.album_info(release) assert d.va def test_parse_artist_sort_name(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.artist_sort == "ARTIST SORT NAME" def test_parse_releasegroupid(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.releasegroup_id == "RELEASE GROUP ID" def test_parse_release_group_title(self): release = self._make_release(None) release["release-group"]["aliases"] = [ make_alias(suffix="en", locale="en", primary=True), ] # test no alias config["import"]["languages"] = [] d = self.mb.album_info(release) assert d.release_group_title == "RELEASE GROUP TITLE" # test en primary config["import"]["languages"] = ["en"] d = self.mb.album_info(release) assert d.release_group_title == "ALIASen" def test_parse_asin(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.asin == "ALBUM ASIN" def test_parse_catalognum(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.catalognum == "CATALOG NUMBER" def test_parse_textrepr(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.script == "SCRIPT" assert d.language == "LANGUAGE" def test_parse_country(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.country == "COUNTRY" def test_parse_status(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.albumstatus == "STATUS" def test_parse_barcode(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.barcode == "BARCODE" def test_parse_media(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(None, tracks=tracks) d = self.mb.album_info(release) assert d.media == "FORMAT" def test_parse_disambig(self): release = self._make_release(None) d = self.mb.album_info(release) assert d.albumdisambig == "R_DISAMBIGUATION" assert d.releasegroupdisambig == "RG_DISAMBIGUATION" def test_parse_disctitle(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(None, tracks=tracks) d = self.mb.album_info(release) t = d.tracks assert t[0].disctitle == "MEDIUM TITLE" assert t[1].disctitle == "MEDIUM TITLE" def test_missing_language(self): release = self._make_release(None) del release["text-representation"]["language"] d = self.mb.album_info(release) assert d.language is None def test_parse_recording_artist(self): tracks = [self._make_track("a", "b", 1, True)] release = self._make_release(None, tracks=tracks) track = self.mb.album_info(release).tracks[0] assert track.artist == "RECORDING ARTIST NAME" assert track.artist_id == "RECORDING ARTIST ID" assert track.artist_sort == "RECORDING ARTIST SORT NAME" assert track.artist_credit == "RECORDING ARTIST CREDIT" def test_parse_recording_artist_multi(self): tracks = [self._make_track("a", "b", 1, True, multi_artist_credit=True)] release = self._make_release(None, tracks=tracks) track = self.mb.album_info(release).tracks[0] assert track.artist == "RECORDING ARTIST NAME & RECORDING ARTIST 2 NAME" assert track.artist_id == "RECORDING ARTIST ID" assert ( track.artist_sort == "RECORDING ARTIST SORT NAME & RECORDING ARTIST 2 SORT NAME" ) assert ( track.artist_credit == "RECORDING ARTIST CREDIT & RECORDING ARTIST 2 CREDIT" ) assert track.artists == [ "RECORDING ARTIST NAME", "RECORDING ARTIST 2 NAME", ] assert track.artists_ids == [ "RECORDING ARTIST ID", "RECORDING ARTIST 2 ID", ] assert track.artists_sort == [ "RECORDING ARTIST SORT NAME", "RECORDING ARTIST 2 SORT NAME", ] assert track.artists_credit == [ "RECORDING ARTIST CREDIT", "RECORDING ARTIST 2 CREDIT", ] def test_track_artist_overrides_recording_artist(self): tracks = [self._make_track("a", "b", 1, True)] release = self._make_release(None, tracks=tracks, track_artist=True) track = self.mb.album_info(release).tracks[0] assert track.artist == "TRACK ARTIST NAME" assert track.artist_id == "TRACK ARTIST ID" assert track.artist_sort == "TRACK ARTIST SORT NAME" assert track.artist_credit == "TRACK ARTIST CREDIT" def test_track_artist_overrides_recording_artist_multi(self): tracks = [self._make_track("a", "b", 1, True, multi_artist_credit=True)] release = self._make_release( None, tracks=tracks, track_artist=True, multi_artist_credit=True ) track = self.mb.album_info(release).tracks[0] assert track.artist == "TRACK ARTIST NAME & TRACK ARTIST 2 NAME" assert track.artist_id == "TRACK ARTIST ID" assert ( track.artist_sort == "TRACK ARTIST SORT NAME & TRACK ARTIST 2 SORT NAME" ) assert ( track.artist_credit == "TRACK ARTIST CREDIT & TRACK ARTIST 2 CREDIT" ) assert track.artists == ["TRACK ARTIST NAME", "TRACK ARTIST 2 NAME"] assert track.artists_ids == ["TRACK ARTIST ID", "TRACK ARTIST 2 ID"] assert track.artists_sort == [ "TRACK ARTIST SORT NAME", "TRACK ARTIST 2 SORT NAME", ] assert track.artists_credit == [ "TRACK ARTIST CREDIT", "TRACK ARTIST 2 CREDIT", ] def test_parse_recording_remixer(self): tracks = [self._make_track("a", "b", 1, remixer=True)] release = self._make_release(None, tracks=tracks) track = self.mb.album_info(release).tracks[0] assert track.remixer == "RECORDING REMIXER ARTIST NAME" def test_data_source(self): release = self._make_release() d = self.mb.album_info(release) assert d.data_source == "MusicBrainz" def test_genres(self): config["musicbrainz"]["genres"] = True config["musicbrainz"]["genres_tag"] = "genre" release = self._make_release() d = self.mb.album_info(release) assert d.genres == ["GENRE"] def test_tags(self): config["musicbrainz"]["genres"] = True config["musicbrainz"]["genres_tag"] = "tag" release = self._make_release() d = self.mb.album_info(release) assert d.genres == ["TAG"] def test_no_genres(self): config["musicbrainz"]["genres"] = False release = self._make_release() d = self.mb.album_info(release) assert d.genres is None def test_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks, medium_format="IGNORED1") d = self.mb.album_info(release) assert len(d.tracks) == 0 def test_no_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks, medium_format="NON-IGNORED") d = self.mb.album_info(release) assert len(d.tracks) == 2 def test_skip_data_track(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("[data track]", "ID DATA TRACK", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" def test_skip_audio_data_tracks_by_default(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ self._make_track( "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 ) ] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" def test_no_skip_audio_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ self._make_track( "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 ) ] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" assert d.tracks[2].title == "TITLE AUDIO DATA" def test_skip_video_tracks_by_default(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" def test_skip_video_data_tracks_by_default(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ self._make_track( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ) ] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" def test_no_skip_video_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE VIDEO" assert d.tracks[2].title == "TITLE TWO" def test_no_skip_video_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ self._make_track( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ) ] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" assert d.tracks[1].title == "TITLE TWO" assert d.tracks[2].title == "TITLE VIDEO" def test_track_disambiguation(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track( "TITLE TWO", "ID TWO", 200.0 * 1000.0, disambiguation="SECOND TRACK", ), ] release = self._make_release(tracks=tracks) d = self.mb.album_info(release) t = d.tracks assert len(t) == 2 assert t[0].trackdisambig is None assert t[1].trackdisambig == "SECOND TRACK" def test_missing_tracks(self): tracks = [ self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_track( "TITLE TWO", "ID TWO", 200.0 * 1000.0, disambiguation="SECOND TRACK", ), ] release = self._make_release(tracks=tracks) release["media"].append(release["media"][0]) del release["media"][0]["tracks"] del release["media"][0]["data-tracks"] d = self.mb.album_info(release) assert d.mediums == 2 class ArtistFlatteningTest(unittest.TestCase): def _credit_dict(self, suffix=""): return { "artist": { "name": f"NAME{suffix}", "sort-name": f"SORT{suffix}", }, "name": f"CREDIT{suffix}", } def test_single_artist(self): credit = [self._credit_dict()] a, s, c = musicbrainz._flatten_artist_credit(credit) assert a == "NAME" assert s == "SORT" assert c == "CREDIT" a, s, c = musicbrainz._multi_artist_credit( credit, include_join_phrase=False ) assert a == ["NAME"] assert s == ["SORT"] assert c == ["CREDIT"] def test_two_artists(self): credit = [ {**self._credit_dict("a"), "joinphrase": " AND "}, self._credit_dict("b"), ] a, s, c = musicbrainz._flatten_artist_credit(credit) assert a == "NAMEa AND NAMEb" assert s == "SORTa AND SORTb" assert c == "CREDITa AND CREDITb" a, s, c = musicbrainz._multi_artist_credit( credit, include_join_phrase=False ) assert a == ["NAMEa", "NAMEb"] assert s == ["SORTa", "SORTb"] assert c == ["CREDITa", "CREDITb"] def test_alias(self): credit_dict = self._credit_dict() credit_dict["artist"]["aliases"] = [ make_alias(suffix="en", locale="en", primary=True), make_alias(suffix="en_GB", locale="en_GB", primary=True), make_alias(suffix="fr", locale="fr"), make_alias(suffix="fr_P", locale="fr", primary=True), make_alias(suffix="pt_BR", locale="pt_BR"), ] # test no alias config["import"]["languages"] = [] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("NAME", "SORT", "CREDIT") # test en primary config["import"]["languages"] = ["en"] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("ALIASen", "ALIASSORTen", "CREDIT") # test en_GB en primary config["import"]["languages"] = ["en_GB", "en"] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("ALIASen_GB", "ALIASSORTen_GB", "CREDIT") # test en en_GB primary config["import"]["languages"] = ["en", "en_GB"] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("ALIASen", "ALIASSORTen", "CREDIT") # test fr primary config["import"]["languages"] = ["fr"] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("ALIASfr_P", "ALIASSORTfr_P", "CREDIT") # test for not matching non-primary config["import"]["languages"] = ["pt_BR", "fr"] flat = musicbrainz._flatten_artist_credit([credit_dict]) assert flat == ("ALIASfr_P", "ALIASSORTfr_P", "CREDIT") class MBLibraryTest(MusicBrainzTestCase): def test_follow_pseudo_releases(self): side_effect = [ { "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", "media": [ { "tracks": [ { "id": "baz", "recording": { "title": "translated title", "id": "bar", "length": 42, }, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ { "artist": { "name": "some-artist", "id": "some-id", }, } ], "release-group": { "id": "another-id", }, "release-relations": [ { "type": "transl-tracklisting", "direction": "backward", "release": { "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" }, } ], }, { "title": "actual", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", "status": "Official", "media": [ { "tracks": [ { "id": "baz", "recording": { "title": "original title", "id": "bar", "length": 42, }, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ { "artist": { "name": "some-artist", "id": "some-id", }, } ], "release-group": { "id": "another-id", }, "country": "COUNTRY", }, ] with mock.patch( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country == "COUNTRY" def test_pseudo_releases_with_empty_links(self): side_effect = [ { "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", "media": [ { "tracks": [ { "id": "baz", "recording": { "title": "translated title", "id": "bar", "length": 42, }, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ { "artist": { "name": "some-artist", "id": "some-id", }, } ], "release-group": { "id": "another-id", }, } ] with mock.patch( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None def test_pseudo_releases_without_links(self): side_effect = [ { "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", "media": [ { "tracks": [ { "id": "baz", "recording": { "title": "translated title", "id": "bar", "length": 42, }, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ { "artist": { "name": "some-artist", "id": "some-id", }, } ], "release-group": { "id": "another-id", }, } ] with mock.patch( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None def test_pseudo_releases_with_unsupported_links(self): side_effect = [ { "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", "media": [ { "tracks": [ { "id": "baz", "recording": { "title": "translated title", "id": "bar", "length": 42, }, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ { "artist": { "name": "some-artist", "id": "some-id", }, } ], "release-group": { "id": "another-id", }, "release-relations": [ { "type": "remaster", "direction": "backward", "release": { "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01" }, } ], } ] with mock.patch( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release" ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") assert album.country is None class TestMusicBrainzPlugin(PluginMixin): plugin = "musicbrainz" mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" RECORDING: ClassVar[dict[str, int | str]] = { "title": "foo", "id": mbid, "length": 42, } @pytest.fixture def plugin_config(self): return {} @pytest.fixture def mb(self, plugin_config): self.config[self.plugin].set(plugin_config) return musicbrainz.MusicBrainzPlugin() @pytest.mark.parametrize( "plugin_config,va_likely,expected_additional_criteria", [ ({}, False, {"artist": "Artist "}), ({}, True, {"arid": "89ad4ac3-39f7-470e-963a-56509c546377"}), ( {"extra_tags": ["label", "catalognum"]}, False, {"artist": "Artist ", "label": "abc", "catno": "ABC123"}, ), ], ) def test_get_album_criteria( self, mb, va_likely, expected_additional_criteria ): items = [ Item(catalognum="ABC 123", label="abc"), Item(catalognum="ABC 123", label="abc"), Item(catalognum="ABC 123", label="def"), ] assert mb.get_album_criteria(items, "Artist ", " Album", va_likely) == { "release": " Album", **expected_additional_criteria, } def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"recordings": [self.RECORDING]}, ) monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_recording", lambda *_, **__: self.RECORDING, ) candidates = list(mb.item_candidates(Item(), "hello", "there")) assert len(candidates) == 1 assert candidates[0].track_id == self.RECORDING["id"] def test_candidates(self, monkeypatch, mb): monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", lambda *_, **__: {"releases": [{"id": self.mbid}]}, ) monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", lambda *_, **__: { "title": "hi", "id": self.mbid, "status": "status", "media": [ { "tracks": [ { "id": "baz", "recording": self.RECORDING, "position": 9, "number": "A1", } ], "position": 5, } ], "artist-credit": [ {"artist": {"name": "some-artist", "id": "some-id"}} ], "release-group": {"id": "another-id"}, }, ) candidates = list(mb.candidates([], "hello", "there", False)) assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] assert candidates[0].album == "hi" def test_import_handles_404_gracefully(self, mb, requests_mock): id_ = uuid.uuid4() response = requests.Response() response.status_code = 404 requests_mock.get( f"/ws/2/release/{id_}", exc=requests.exceptions.HTTPError(response=response), ) res = mb.album_for_id(str(id_)) assert res is None def test_import_propagates_non_404_errors(self, mb): class DummyResponse: status_code = 500 error = requests.exceptions.HTTPError(response=DummyResponse()) def raise_error(*args, **kwargs): raise error # Simulate mb.mb_api.get_release raising a non-404 HTTP error mb.mb_api.get_release = raise_error with pytest.raises(requests.exceptions.HTTPError) as excinfo: mb.album_for_id(str(uuid.uuid4())) # Ensure the exact error is propagated, not swallowed assert excinfo.value is error ================================================ FILE: test/plugins/test_parentwork.py ================================================ # This file is part of beets. # Copyright 2017, Dorian Soergel # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'parentwork' plugin.""" import pytest from beets.library import Item from beets.test.helper import PluginTestCase @pytest.mark.integration_test class ParentWorkIntegrationTest(PluginTestCase): plugin = "parentwork" # test how it works with real musicbrainz data def test_normal_case_real(self): item = Item( path="/file", mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", ) item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "32c8943f-1b27-3a23-8660-4567f4847c94" def test_force_real(self): self.config["parentwork"]["force"] = True item = Item( path="/file", mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", mb_parentworkid="XXX", parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", parentwork="whatever", ) item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "32c8943f-1b27-3a23-8660-4567f4847c94" def test_no_force_real(self): self.config["parentwork"]["force"] = False item = Item( path="/file", mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", mb_parentworkid="XXX", parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53", parentwork="whatever", ) item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "XXX" class ParentWorkTest(PluginTestCase): plugin = "parentwork" @pytest.fixture(autouse=True) def patch_works(self, requests_mock): requests_mock.get( "/ws/2/work/1?inc=work-rels%2Bartist-rels", json={ "id": "1", "title": "work", "work-relations": [ { "type": "parts", "direction": "backward", "work": {"id": "2"}, } ], }, ) requests_mock.get( "/ws/2/work/2?inc=work-rels%2Bartist-rels", json={ "id": "2", "title": "directparentwork", "work-relations": [ { "type": "parts", "direction": "backward", "work": {"id": "3"}, } ], }, ) requests_mock.get( "/ws/2/work/3?inc=work-rels%2Bartist-rels", json={ "id": "3", "title": "parentwork", "artist-relations": [ { "type": "composer", "artist": { "name": "random composer", "sort-name": "composer, random", }, } ], }, ) def test_normal_case(self): item = Item(path="/file", mb_workid="1", parentwork_workid_current="1") item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "3" def test_force(self): self.config["parentwork"]["force"] = True item = Item( path="/file", mb_workid="1", mb_parentworkid="XXX", parentwork_workid_current="1", parentwork="parentwork", ) item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "3" def test_no_force(self): self.config["parentwork"]["force"] = False item = Item( path="/file", mb_workid="1", mb_parentworkid="XXX", parentwork_workid_current="1", parentwork="parentwork", ) item.add(self.lib) self.run_command("parentwork") item.load() assert item["mb_parentworkid"] == "XXX" ================================================ FILE: test/plugins/test_permissions.py ================================================ """Tests for the 'permissions' plugin.""" import os import platform from unittest.mock import Mock, patch from beets.test._common import touch from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin from beetsplug.permissions import ( check_permissions, convert_perm, dirs_in_library, ) class PermissionsPluginTest(AsIsImporterMixin, PluginMixin, ImportTestCase): plugin = "permissions" def setUp(self): super().setUp() self.config["permissions"] = {"file": "777", "dir": "777"} def test_permissions_on_album_imported(self): self.import_and_check_permissions() def test_permissions_on_item_imported(self): self.config["import"]["singletons"] = True self.import_and_check_permissions() def import_and_check_permissions(self): if platform.system() == "Windows": self.skipTest("permissions not available on Windows") track_file = os.path.join(self.import_dir, b"album", b"track_1.mp3") assert os.stat(track_file).st_mode & 0o777 != 511 self.run_asis_importer() item = self.lib.items().get() paths = (item.path, *dirs_in_library(self.lib.directory, item.path)) for path in paths: assert os.stat(path).st_mode & 0o777 == 511 def test_convert_perm_from_string(self): assert convert_perm("10") == 8 def test_convert_perm_from_int(self): assert convert_perm(10) == 8 def test_permissions_on_set_art(self): self.do_set_art(True) @patch("os.chmod", Mock()) def test_failing_permissions_on_set_art(self): self.do_set_art(False) def do_set_art(self, expect_success): if platform.system() == "Windows": self.skipTest("permissions not available on Windows") self.run_asis_importer() album = self.lib.albums().get() artpath = os.path.join(self.temp_dir, b"cover.jpg") touch(artpath) album.set_art(artpath) assert expect_success == check_permissions(album.artpath, 0o777) ================================================ FILE: test/plugins/test_play.py ================================================ # This file is part of beets. # Copyright 2016, Jesse Weinstein # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the play plugin""" import os import sys import unittest from unittest.mock import ANY, patch import pytest from beets.test.helper import CleanupModulesMixin, IOMixin, PluginTestCase from beets.ui import UserError from beets.util import open_anything from beetsplug.play import PlayPlugin @patch("beetsplug.play.util.interactive_open") class PlayPluginTest(IOMixin, CleanupModulesMixin, PluginTestCase): modules = (PlayPlugin.__module__,) plugin = "play" def setUp(self): super().setUp() self.item = self.add_item(album="a nice älbum", title="aNiceTitle") self.lib.add_album([self.item]) self.config["play"]["command"] = "echo" def run_and_assert( self, open_mock, args=("title:aNiceTitle",), expected_cmd="echo", expected_playlist=None, ): self.run_command("play", *args) open_mock.assert_called_once_with(ANY, expected_cmd) expected_playlist = expected_playlist or self.item.path.decode("utf-8") exp_playlist = f"{expected_playlist}\n" with open(open_mock.call_args[0][0][0], "rb") as playlist: assert exp_playlist == playlist.read().decode("utf-8") def test_basic(self, open_mock): self.run_and_assert(open_mock) def test_album_option(self, open_mock): self.run_and_assert(open_mock, ["-a", "nice"]) def test_args_option(self, open_mock): self.run_and_assert( open_mock, ["-A", "foo", "title:aNiceTitle"], "echo foo" ) def test_args_option_in_middle(self, open_mock): self.config["play"]["command"] = "echo $args other" self.run_and_assert( open_mock, ["-A", "foo", "title:aNiceTitle"], "echo foo other" ) def test_unset_args_option_in_middle(self, open_mock): self.config["play"]["command"] = "echo $args other" self.run_and_assert(open_mock, ["title:aNiceTitle"], "echo other") # FIXME: fails on windows @unittest.skipIf(sys.platform == "win32", "win32") def test_relative_to(self, open_mock): self.config["play"]["command"] = "echo" self.config["play"]["relative_to"] = "/something" path = os.path.relpath(self.item.path, b"/something") playlist = path.decode("utf-8") self.run_and_assert( open_mock, expected_cmd="echo", expected_playlist=playlist ) def test_use_folders(self, open_mock): self.config["play"]["command"] = None self.config["play"]["use_folders"] = True self.run_command("play", "-a", "nice") open_mock.assert_called_once_with(ANY, open_anything()) with open(open_mock.call_args[0][0][0], "rb") as f: playlist = f.read().decode("utf-8") assert f"{self.item.filepath.parent}\n" == playlist def test_raw(self, open_mock): self.config["play"]["raw"] = True self.run_command("play", "nice") open_mock.assert_called_once_with([self.item.path], "echo") def test_pls_marker(self, open_mock): self.config["play"]["command"] = ( "echo --some params --playlist=$playlist --some-more params" ) self.run_command("play", "nice") open_mock.assert_called_once commandstr = open_mock.call_args_list[0][0][1] assert commandstr.startswith("echo --some params --playlist=") assert commandstr.endswith(" --some-more params") def test_not_found(self, open_mock): self.run_command("play", "not found") open_mock.assert_not_called() def test_warning_threshold(self, open_mock): self.config["play"]["warning_threshold"] = 1 self.add_item(title="another NiceTitle") self.io.addinput("a") self.run_command("play", "nice") open_mock.assert_not_called() def test_skip_warning_threshold_bypass(self, open_mock): self.config["play"]["warning_threshold"] = 1 self.other_item = self.add_item(title="another NiceTitle") expected_playlist = f"{self.item.filepath}\n{self.other_item.filepath}" self.io.addinput("a") self.run_and_assert( open_mock, ["-y", "NiceTitle"], expected_playlist=expected_playlist, ) def _playlist_lines(self, open_mock): """Read the playlist file passed to interactive_open and return its lines.""" # interactive_open is called as: interactive_open([playlist_path], command) playlist_path = open_mock.call_args[0][0][0] with open(playlist_path, "rb") as playlist: return playlist.read().decode("utf-8").splitlines() def _add_many_ordered_items(self, *, count, album): items = [] for track in range(1, count + 1): items.append( self.add_item( album=album, artist="randomize artist", title=f"randomize {track:03d}", track=track, ) ) return items def test_randomize(self, open_mock): album = "randomize_test" items = self._add_many_ordered_items(count=100, album=album) baseline = [str(item.filepath) for item in items] self.run_command("play", "-R", f"album:{album}") lines = self._playlist_lines(open_mock) assert sorted(lines) == sorted(baseline), ( "playlist items are not the same" ) assert lines != baseline, "playlist order hasn't changed" def test_command_failed(self, open_mock): open_mock.side_effect = OSError("some reason") with pytest.raises(UserError): self.run_command("play", "title:aNiceTitle") ================================================ FILE: test/plugins/test_playlist.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os from shlex import quote import beets from beets.test import _common from beets.test.helper import PluginTestCase class PlaylistTestCase(PluginTestCase): plugin = "playlist" preload_plugin = False def setUp(self): super().setUp() self.music_dir = os.path.expanduser(os.path.join("~", "Music")) i1 = _common.item() i1.path = beets.util.normpath( os.path.join( self.music_dir, "a", "b", "c.mp3", ) ) i1.title = "some item" i1.album = "some album" self.lib.add(i1) self.lib.add_album([i1]) i2 = _common.item() i2.path = beets.util.normpath( os.path.join( self.music_dir, "d", "e", "f.mp3", ) ) i2.title = "another item" i2.album = "another album" self.lib.add(i2) self.lib.add_album([i2]) i3 = _common.item() i3.path = beets.util.normpath( os.path.join( self.music_dir, "x", "y", "z.mp3", ) ) i3.title = "yet another item" i3.album = "yet another album" self.lib.add(i3) self.lib.add_album([i3]) self.playlist_dir = self.temp_dir_path / "playlists" self.playlist_dir.mkdir(parents=True, exist_ok=True) self.config["directory"] = self.music_dir self.config["playlist"]["playlist_dir"] = str(self.playlist_dir) self.setup_test() self.load_plugins() def setup_test(self): raise NotImplementedError class PlaylistQueryTest: def test_name_query_with_absolute_paths_in_playlist(self): q = "playlist:absolute" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} def test_path_query_with_absolute_paths_in_playlist(self): q = f"playlist:{quote(os.path.join(self.playlist_dir, 'absolute.m3u'))}" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} def test_name_query_with_relative_paths_in_playlist(self): q = "playlist:relative" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} def test_path_query_with_relative_paths_in_playlist(self): q = f"playlist:{quote(os.path.join(self.playlist_dir, 'relative.m3u'))}" results = self.lib.items(q) assert {i.title for i in results} == {"some item", "another item"} def test_name_query_with_nonexisting_playlist(self): q = "playlist:nonexisting" results = self.lib.items(q) assert set(results) == set() def test_path_query_with_nonexisting_playlist(self): q = f"playlist:{os.path.join(self.playlist_dir, 'nonexisting.m3u')!r}" results = self.lib.items(q) assert set(results) == set() class PlaylistTestRelativeToLib(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: f.writelines( [ os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", os.path.join(self.music_dir, "nonexisting.mp3") + "\n", ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: f.writelines( [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", "nonexisting.mp3\n", ] ) self.config["playlist"]["relative_to"] = "library" class PlaylistTestRelativeToDir(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: f.writelines( [ os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", os.path.join(self.music_dir, "nonexisting.mp3") + "\n", ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: f.writelines( [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", "nonexisting.mp3\n", ] ) self.config["playlist"]["relative_to"] = self.music_dir class PlaylistTestRelativeToPls(PlaylistQueryTest, PlaylistTestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: f.writelines( [ os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", os.path.join(self.music_dir, "nonexisting.mp3") + "\n", ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: f.writelines( [ os.path.relpath( os.path.join(self.music_dir, "a", "b", "c.mp3"), start=self.playlist_dir, ) + "\n", os.path.relpath( os.path.join(self.music_dir, "d", "e", "f.mp3"), start=self.playlist_dir, ) + "\n", os.path.relpath( os.path.join(self.music_dir, "nonexisting.mp3"), start=self.playlist_dir, ) + "\n", ] ) self.config["playlist"]["relative_to"] = "playlist" self.config["playlist"]["playlist_dir"] = str(self.playlist_dir) class PlaylistUpdateTest: def setup_test(self): with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f: f.writelines( [ os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n", os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n", os.path.join(self.music_dir, "nonexisting.mp3") + "\n", ] ) with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f: f.writelines( [ os.path.join("a", "b", "c.mp3") + "\n", os.path.join("d", "e", "f.mp3") + "\n", "nonexisting.mp3\n", ] ) self.config["playlist"]["auto"] = True self.config["playlist"]["relative_to"] = "library" class PlaylistTestItemMoved(PlaylistUpdateTest, PlaylistTestCase): def test_item_moved(self): # Emit item_moved event for an item that is in a playlist results = self.lib.items( f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}" ) item = results[0] beets.plugins.send( "item_moved", item=item, source=item.path, destination=beets.util.bytestring_path( os.path.join(self.music_dir, "g", "h", "i.mp3") ), ) # Emit item_moved event for an item that is not in a playlist results = self.lib.items( f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}" ) item = results[0] beets.plugins.send( "item_moved", item=item, source=item.path, destination=beets.util.bytestring_path( os.path.join(self.music_dir, "u", "v", "w.mp3") ), ) # Emit cli_exit event beets.plugins.send("cli_exit", lib=self.lib) # Check playlist with absolute paths playlist_path = os.path.join(self.playlist_dir, "absolute.m3u") with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] assert lines == [ os.path.join(self.music_dir, "a", "b", "c.mp3"), os.path.join(self.music_dir, "g", "h", "i.mp3"), os.path.join(self.music_dir, "nonexisting.mp3"), ] # Check playlist with relative paths playlist_path = os.path.join(self.playlist_dir, "relative.m3u") with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] assert lines == [ os.path.join("a", "b", "c.mp3"), os.path.join("g", "h", "i.mp3"), "nonexisting.mp3", ] class PlaylistTestItemRemoved(PlaylistUpdateTest, PlaylistTestCase): def test_item_removed(self): # Emit item_removed event for an item that is in a playlist results = self.lib.items( f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}" ) item = results[0] beets.plugins.send("item_removed", item=item) # Emit item_removed event for an item that is not in a playlist results = self.lib.items( f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}" ) item = results[0] beets.plugins.send("item_removed", item=item) # Emit cli_exit event beets.plugins.send("cli_exit", lib=self.lib) # Check playlist with absolute paths playlist_path = os.path.join(self.playlist_dir, "absolute.m3u") with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] assert lines == [ os.path.join(self.music_dir, "a", "b", "c.mp3"), os.path.join(self.music_dir, "nonexisting.mp3"), ] # Check playlist with relative paths playlist_path = os.path.join(self.playlist_dir, "relative.m3u") with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] assert lines == [os.path.join("a", "b", "c.mp3"), "nonexisting.mp3"] ================================================ FILE: test/plugins/test_plexupdate.py ================================================ import responses from beets.test.helper import PluginTestCase from beetsplug.plexupdate import get_music_section, update_plex class PlexUpdateTest(PluginTestCase): plugin = "plexupdate" def add_response_get_music_section(self, section_name="Music"): """Create response for mocking the get_music_section function.""" escaped_section_name = section_name.replace('"', '\\"') body = ( '<?xml version="1.0" encoding="UTF-8"?>' '<MediaContainer size="3" allowSync="0" ' 'identifier="com.plexapp.plugins.library" ' 'mediaTagPrefix="/system/bundle/media/flags/" ' 'mediaTagVersion="1413367228" title1="Plex Library">' '<Directory allowSync="0" art="/:/resources/movie-fanart.jpg" ' 'filters="1" refreshing="0" thumb="/:/resources/movie.png" ' 'key="3" type="movie" title="Movies" ' 'composite="/library/sections/3/composite/1416232668" ' 'agent="com.plexapp.agents.imdb" scanner="Plex Movie Scanner" ' 'language="de" uuid="92f68526-21eb-4ee2-8e22-d36355a17f1f" ' 'updatedAt="1416232668" createdAt="1415720680">' '<Location id="3" path="/home/marv/Media/Videos/Movies" />' "</Directory>" '<Directory allowSync="0" art="/:/resources/artist-fanart.jpg" ' 'filters="1" refreshing="0" thumb="/:/resources/artist.png" ' f'key="2" type="artist" title="{escaped_section_name}" ' 'composite="/library/sections/2/composite/1416929243" ' 'agent="com.plexapp.agents.lastfm" scanner="Plex Music Scanner" ' 'language="en" uuid="90897c95-b3bd-4778-a9c8-1f43cb78f047" ' 'updatedAt="1416929243" createdAt="1415691331">' '<Location id="2" path="/home/marv/Media/Musik" />' "</Directory>" '<Directory allowSync="0" art="/:/resources/show-fanart.jpg" ' 'filters="1" refreshing="0" thumb="/:/resources/show.png" ' 'key="1" type="show" title="TV Shows" ' 'composite="/library/sections/1/composite/1416320800" ' 'agent="com.plexapp.agents.thetvdb" scanner="Plex Series Scanner" ' 'language="de" uuid="04d2249b-160a-4ae9-8100-106f4ec1a218" ' 'updatedAt="1416320800" createdAt="1415690983">' '<Location id="1" path="/home/marv/Media/Videos/Series" />' "</Directory>" "</MediaContainer>" ) status = 200 content_type = "text/xml;charset=utf-8" responses.add( responses.GET, "http://localhost:32400/library/sections", body=body, status=status, content_type=content_type, ) def add_response_update_plex(self): """Create response for mocking the update_plex function.""" body = "" status = 200 content_type = "text/html" responses.add( responses.GET, "http://localhost:32400/library/sections/2/refresh", body=body, status=status, content_type=content_type, ) def setUp(self): super().setUp() self.config["plex"] = {"host": "localhost", "port": 32400} @responses.activate def test_get_music_section(self): # Adding response. self.add_response_get_music_section() # Test if section key is "2" out of the mocking data. assert ( get_music_section( self.config["plex"]["host"], self.config["plex"]["port"], self.config["plex"]["token"], self.config["plex"]["library_name"].get(), self.config["plex"]["secure"], self.config["plex"]["ignore_cert_errors"], ) == "2" ) @responses.activate def test_get_named_music_section(self): # Adding response. self.add_response_get_music_section("My Music Library") assert ( get_music_section( self.config["plex"]["host"], self.config["plex"]["port"], self.config["plex"]["token"], "My Music Library", self.config["plex"]["secure"], self.config["plex"]["ignore_cert_errors"], ) == "2" ) @responses.activate def test_update_plex(self): # Adding responses. self.add_response_get_music_section() self.add_response_update_plex() # Testing status code of the mocking request. assert ( update_plex( self.config["plex"]["host"], self.config["plex"]["port"], self.config["plex"]["token"], self.config["plex"]["library_name"].get(), self.config["plex"]["secure"], self.config["plex"]["ignore_cert_errors"], ).status_code == 200 ) ================================================ FILE: test/plugins/test_plugin_mediafield.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the facility that lets plugins add custom field to MediaFile.""" import os import shutil import mediafile import pytest from beets.library import Item from beets.plugins import BeetsPlugin from beets.test import _common from beets.test.helper import BeetsTestCase from beets.util import bytestring_path, syspath field_extension = mediafile.MediaField( mediafile.MP3DescStorageStyle("customtag"), mediafile.MP4StorageStyle("----:com.apple.iTunes:customtag"), mediafile.StorageStyle("customtag"), mediafile.ASFStorageStyle("customtag"), ) list_field_extension = mediafile.ListMediaField( mediafile.MP3ListDescStorageStyle("customlisttag"), mediafile.MP4ListStorageStyle("----:com.apple.iTunes:customlisttag"), mediafile.ListStorageStyle("customlisttag"), mediafile.ASFStorageStyle("customlisttag"), ) class ExtendedFieldTestMixin(BeetsTestCase): def _mediafile_fixture(self, name, extension="mp3"): name = bytestring_path(f"{name}.{extension}") src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(syspath(src), syspath(target)) return mediafile.MediaFile(target) def test_extended_field_write(self): plugin = BeetsPlugin() plugin.add_media_field("customtag", field_extension) try: mf = self._mediafile_fixture("empty") mf.customtag = "F#" mf.save() mf = mediafile.MediaFile(mf.path) assert mf.customtag == "F#" finally: delattr(mediafile.MediaFile, "customtag") Item._media_fields.remove("customtag") def test_extended_list_field_write(self): plugin = BeetsPlugin() plugin.add_media_field("customlisttag", list_field_extension) try: mf = self._mediafile_fixture("empty") mf.customlisttag = ["a", "b"] mf.save() mf = mediafile.MediaFile(mf.path) assert mf.customlisttag == ["a", "b"] finally: delattr(mediafile.MediaFile, "customlisttag") Item._media_fields.remove("customlisttag") def test_write_extended_tag_from_item(self): plugin = BeetsPlugin() plugin.add_media_field("customtag", field_extension) try: mf = self._mediafile_fixture("empty") assert mf.customtag is None item = Item(path=mf.path, customtag="Gb") item.write() mf = mediafile.MediaFile(mf.path) assert mf.customtag == "Gb" finally: delattr(mediafile.MediaFile, "customtag") Item._media_fields.remove("customtag") def test_read_flexible_attribute_from_file(self): plugin = BeetsPlugin() plugin.add_media_field("customtag", field_extension) try: mf = self._mediafile_fixture("empty") mf.update({"customtag": "F#"}) mf.save() item = Item.from_path(mf.path) assert item["customtag"] == "F#" finally: delattr(mediafile.MediaFile, "customtag") Item._media_fields.remove("customtag") def test_invalid_descriptor(self): with pytest.raises( ValueError, match="must be an instance of MediaField" ): mediafile.MediaFile.add_field("somekey", True) def test_overwrite_property(self): with pytest.raises( ValueError, match='property "artist" already exists' ): mediafile.MediaFile.add_field("artist", mediafile.MediaField()) ================================================ FILE: test/plugins/test_random.py ================================================ # This file is part of beets. # Copyright 2019, Carl Suster # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the beets.random utilities associated with the random plugin.""" import math import random import pytest from beets.test.helper import TestHelper from beetsplug.random import _equal_chance_permutation, random_objs @pytest.fixture(scope="class") def helper(): helper = TestHelper() helper.setup_beets() yield helper helper.teardown_beets() @pytest.fixture(scope="module", autouse=True) def seed_random(): random.seed(12345) class TestEqualChancePermutation: """Test the _equal_chance_permutation function.""" @pytest.fixture(autouse=True) def setup(self, helper): """Set up the test environment with items.""" self.lib = helper.lib self.artist1 = "Artist 1" self.artist2 = "Artist 2" self.item1 = helper.create_item(artist=self.artist1) self.item2 = helper.create_item(artist=self.artist2) self.items = [self.item1, self.item2] for _ in range(8): self.items.append(helper.create_item(artist=self.artist2)) def _stats(self, data): mean = sum(data) / len(data) stdev = math.sqrt(sum((p - mean) ** 2 for p in data) / (len(data) - 1)) quot, rem = divmod(len(data), 2) if rem: median = sorted(data)[quot] else: median = sum(sorted(data)[quot - 1 : quot + 1]) / 2 return mean, stdev, median def test_equal_permutation(self): """We have a list of items where only one item is from artist1 and the rest are from artist2. If we permute weighted by the artist field then the solo track will almost always end up near the start. If we use a different field then it'll be in the middle on average. """ def experiment(field, histogram=False): """Permutes the list of items 500 times and calculates the position of self.item1 each time. Returns stats about that position. """ positions = [] for _ in range(500): shuffled = list( _equal_chance_permutation(self.items, field=field) ) positions.append(shuffled.index(self.item1)) # Print a histogram (useful for debugging). if histogram: for i in range(len(self.items)): print(f"{i:2d} {'*' * positions.count(i)}") return self._stats(positions) _, stdev1, median1 = experiment("artist") _, stdev2, median2 = experiment("track") assert 0 == pytest.approx(median1, abs=1) assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 @pytest.mark.parametrize( "input_items, field, expected", [ ([], "artist", []), ([{"artist": "Artist 1"}], "artist", [{"artist": "Artist 1"}]), # Missing field should not raise an error, but return empty ([{"artist": "Artist 1"}], "nonexistent", []), # Multiple items with the same field value ( [{"artist": "Artist 1"}, {"artist": "Artist 1"}], "artist", [{"artist": "Artist 1"}, {"artist": "Artist 1"}], ), ], ) def test_equal_permutation_items( self, input_items, field, expected, helper ): """Test _equal_chance_permutation with empty input.""" result = list( _equal_chance_permutation( [helper.create_item(**i) for i in input_items], field ) ) for item in expected: for key, value in item.items(): assert any(getattr(r, key) == value for r in result) assert len(result) == len(expected) class TestRandomObjs: """Test the random_objs function.""" @pytest.fixture(autouse=True) def setup(self, helper): """Set up the test environment with items.""" self.lib = helper.lib self.artist1 = "Artist 1" self.artist2 = "Artist 2" self.items = [ helper.create_item(artist=self.artist1, length=180), # 3 minutes helper.create_item(artist=self.artist2, length=240), # 4 minutes helper.create_item(artist=self.artist2, length=300), # 5 minutes ] def test_random_selection_by_count(self): """Test selecting a specific number of items.""" selected = list(random_objs(self.items, "artist", number=2)) assert len(selected) == 2 assert all(item in self.items for item in selected) def test_random_selection_by_time(self): """Test selecting items constrained by total time (minutes).""" selected = list( random_objs(self.items, "artist", time_minutes=6) ) # 6 minutes total_time = ( sum(item.length for item in selected) / 60 ) # Convert to minutes assert total_time <= 6 def test_equal_chance_permutation(self, helper): """Test equal chance permutation ensures balanced artist selection.""" # Add more items to make the test meaningful for _ in range(5): self.items.append( helper.create_item(artist=self.artist1, length=180) ) selected = list( random_objs(self.items, "artist", number=10, equal_chance=True) ) artist_counts = {} for item in selected: artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 # Ensure both artists are represented (not strictly equal due to randomness) assert len(artist_counts) >= 2 def test_empty_input_list(self): """Test behavior with an empty input list.""" selected = list(random_objs([], "artist", number=1)) assert len(selected) == 0 def test_no_constraints_returns_all(self): """Test that no constraints return all items in random order.""" selected = list(random_objs(self.items, "artist", number=3)) assert len(selected) == len(self.items) assert set(selected) == set(self.items) ================================================ FILE: test/plugins/test_replace.py ================================================ import shutil from pathlib import Path import pytest from mediafile import MediaFile from beets import ui from beets.test import _common from beetsplug.replace import ReplacePlugin replace = ReplacePlugin() class TestReplace: @pytest.fixture(autouse=True) def _fake_dir(self, tmp_path): self.fake_dir = tmp_path @pytest.fixture(autouse=True) def _fake_file(self, tmp_path): self.fake_file = tmp_path def test_path_is_dir(self): fake_directory = self.fake_dir / "fakeDir" fake_directory.mkdir() with pytest.raises(ui.UserError): replace.file_check(fake_directory) def test_path_is_unsupported_file(self): fake_file = self.fake_file / "fakefile.txt" fake_file.write_text("test", encoding="utf-8") with pytest.raises(ui.UserError): replace.file_check(fake_file) def test_path_is_supported_file(self): dest = self.fake_file / "full.mp3" src = Path(_common.RSRC.decode()) / "full.mp3" shutil.copyfile(src, dest) mediafile = MediaFile(dest) mediafile.albumartist = "AAA" mediafile.disctitle = "DDD" mediafile.genres = ["a", "b", "c"] mediafile.composer = None mediafile.save() replace.file_check(Path(str(dest))) def test_select_song_valid_choice(self, monkeypatch, capfd): songs = ["Song A", "Song B", "Song C"] monkeypatch.setattr("builtins.input", lambda _: "2") selected_song = replace.select_song(songs) captured = capfd.readouterr() assert "1. Song A" in captured.out assert "2. Song B" in captured.out assert "3. Song C" in captured.out assert selected_song == "Song B" def test_select_song_cancel(self, monkeypatch): songs = ["Song A", "Song B", "Song C"] monkeypatch.setattr("builtins.input", lambda _: "0") selected_song = replace.select_song(songs) assert selected_song is None def test_select_song_invalid_then_valid(self, monkeypatch, capfd): songs = ["Song A", "Song B", "Song C"] inputs = iter(["invalid", "4", "3"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) selected_song = replace.select_song(songs) captured = capfd.readouterr() assert "Invalid input. Please type in a number." in captured.out assert ( "Invalid choice. Please enter a number between 1 and 3." in captured.out ) assert selected_song == "Song C" def test_confirm_replacement_file_not_exist(self): class Song: path = b"test123321.txt" song = Song() with pytest.raises(ui.UserError): replace.confirm_replacement("test", song) def test_confirm_replacement_yes(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" monkeypatch.setattr("builtins.input", lambda _: "YES ") class Song: path = str(src).encode() song = Song() assert replace.confirm_replacement("test", song) is True def test_confirm_replacement_no(self, monkeypatch): src = Path(_common.RSRC.decode()) / "full.mp3" monkeypatch.setattr("builtins.input", lambda _: "test123") class Song: path = str(src).encode() song = Song() assert replace.confirm_replacement("test", song) is False ================================================ FILE: test/plugins/test_replaygain.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import unittest from typing import Any, ClassVar import pytest from mediafile import MediaFile from beets.test.helper import ( AsIsImporterMixin, ImportTestCase, PluginMixin, has_program, ) from beetsplug.replaygain import ( FatalGstreamerPluginReplayGainError, GStreamerBackend, ) try: import gi gi.require_version("Gst", "1.0") GST_AVAILABLE = True except (ImportError, ValueError): GST_AVAILABLE = False GAIN_PROG = next( ( cmd for cmd in ["mp3gain", "mp3rgain", "aacgain"] if has_program(cmd, ["-v"]) ), None, ) FFMPEG_AVAILABLE = has_program("ffmpeg", ["-version"]) def reset_replaygain(item): item["rg_track_peak"] = None item["rg_track_gain"] = None item["rg_album_gain"] = None item["rg_album_gain"] = None item["r128_track_gain"] = None item["r128_album_gain"] = None item.write() item.store() class ReplayGainTestCase(PluginMixin, ImportTestCase): db_on_disk = True plugin = "replaygain" preload_plugin = False plugin_config: ClassVar[dict[str, Any]] @property def backend(self): return self.plugin_config["backend"] def setUp(self): # Implemented by Mixins, see above. This may decide to skip the test. self.test_backend() super().setUp() self.config["replaygain"].set(self.plugin_config) self.load_plugins() class ThreadedImportMixin: def setUp(self): super().setUp() self.config["threaded"] = True class BackendMixin: plugin_config: ClassVar[dict[str, Any]] has_r128_support: bool def test_backend(self): """Check whether the backend actually has all required functionality.""" class GstBackendMixin(BackendMixin): plugin_config: ClassVar[dict[str, Any]] = {"backend": "gstreamer"} has_r128_support = True def test_backend(self): """Check whether the backend actually has all required functionality.""" try: # Check if required plugins can be loaded by instantiating a # GStreamerBackend (via its .__init__). self.config["replaygain"]["targetlevel"] = 89 GStreamerBackend(self.config["replaygain"], None) except FatalGstreamerPluginReplayGainError as e: # Skip the test if plugins could not be loaded. self.skipTest(str(e)) class CmdBackendMixin(BackendMixin): plugin_config: ClassVar[dict[str, Any]] = { "backend": "command", "command": GAIN_PROG, } has_r128_support = False class FfmpegBackendMixin(BackendMixin): plugin_config: ClassVar[dict[str, Any]] = {"backend": "ffmpeg"} has_r128_support = True class ReplayGainCliTest: FNAME: str def _add_album(self, *args, **kwargs): # Use a file with non-zero volume (most test assets are total silence) album = self.add_album_fixture(*args, fname=self.FNAME, **kwargs) for item in album.items(): reset_replaygain(item) return album def test_cli_saves_track_gain(self): self._add_album(2) for item in self.lib.items(): assert item.rg_track_peak is None assert item.rg_track_gain is None mediafile = MediaFile(item.path) assert mediafile.rg_track_peak is None assert mediafile.rg_track_gain is None self.run_command("replaygain") # Skip the test if rg_track_peak and rg_track gain is None, assuming # that it could only happen if the decoder plugins are missing. if all( i.rg_track_peak is None and i.rg_track_gain is None for i in self.lib.items() ): self.skipTest("decoder plugins could not be loaded.") for item in self.lib.items(): assert item.rg_track_peak is not None assert item.rg_track_gain is not None mediafile = MediaFile(item.path) assert mediafile.rg_track_peak == pytest.approx( item.rg_track_peak, abs=1e-6 ) assert mediafile.rg_track_gain == pytest.approx( item.rg_track_gain, abs=1e-2 ) def test_cli_skips_calculated_tracks(self): album_rg = self._add_album(1) item_rg = album_rg.items()[0] if self.has_r128_support: album_r128 = self._add_album(1, ext="opus") item_r128 = album_r128.items()[0] self.run_command("replaygain") item_rg.load() assert item_rg.rg_track_gain is not None assert item_rg.rg_track_peak is not None assert item_rg.r128_track_gain is None item_rg.rg_track_gain += 1.0 item_rg.rg_track_peak += 1.0 item_rg.store() rg_track_gain = item_rg.rg_track_gain rg_track_peak = item_rg.rg_track_peak if self.has_r128_support: item_r128.load() assert item_r128.r128_track_gain is not None assert item_r128.rg_track_gain is None assert item_r128.rg_track_peak is None item_r128.r128_track_gain += 1.0 item_r128.store() r128_track_gain = item_r128.r128_track_gain self.run_command("replaygain") item_rg.load() assert item_rg.rg_track_gain == rg_track_gain assert item_rg.rg_track_peak == rg_track_peak if self.has_r128_support: item_r128.load() assert item_r128.r128_track_gain == r128_track_gain def test_cli_does_not_skip_wrong_tag_type(self): """Check that items that have tags of the wrong type won't be skipped.""" if not self.has_r128_support: # This test is a lot less interesting if the backend cannot write # both tag types. self.skipTest( f"r128 tags for opus not supported on backend {self.backend}" ) album_rg = self._add_album(1) item_rg = album_rg.items()[0] album_r128 = self._add_album(1, ext="opus") item_r128 = album_r128.items()[0] item_rg.r128_track_gain = 0.0 item_rg.store() item_r128.rg_track_gain = 0.0 item_r128.rg_track_peak = 42.0 item_r128.store() self.run_command("replaygain") item_rg.load() item_r128.load() assert item_rg.rg_track_gain is not None assert item_rg.rg_track_peak is not None # FIXME: Should the plugin null this field? # assert item_rg.r128_track_gain is None assert item_r128.r128_track_gain is not None # FIXME: Should the plugin null these fields? # assert item_r128.rg_track_gain is None # assert item_r128.rg_track_peak is None def test_cli_saves_album_gain_to_file(self): self._add_album(2) for item in self.lib.items(): mediafile = MediaFile(item.path) assert mediafile.rg_album_peak is None assert mediafile.rg_album_gain is None self.run_command("replaygain", "-a") peaks = [] gains = [] for item in self.lib.items(): mediafile = MediaFile(item.path) peaks.append(mediafile.rg_album_peak) gains.append(mediafile.rg_album_gain) # Make sure they are all the same assert max(peaks) == min(peaks) assert max(gains) == min(gains) assert max(gains) != 0.0 assert max(peaks) != 0.0 def test_cli_writes_only_r128_tags(self): if not self.has_r128_support: self.skipTest( f"r128 tags for opus not supported on backend {self.backend}" ) album = self._add_album(2, ext="opus") self.run_command("replaygain", "-a") for item in album.items(): mediafile = MediaFile(item.path) # does not write REPLAYGAIN_* tags assert mediafile.rg_track_gain is None assert mediafile.rg_album_gain is None # writes R128_* tags assert mediafile.r128_track_gain is not None assert mediafile.r128_album_gain is not None def test_targetlevel_has_effect(self): album = self._add_album(1) item = album.items()[0] def analyse(target_level): self.config["replaygain"]["targetlevel"] = target_level self.run_command("replaygain", "-f") item.load() return item.rg_track_gain gain_relative_to_84 = analyse(84) gain_relative_to_89 = analyse(89) assert gain_relative_to_84 != gain_relative_to_89 def test_r128_targetlevel_has_effect(self): if not self.has_r128_support: self.skipTest( f"r128 tags for opus not supported on backend {self.backend}" ) album = self._add_album(1, ext="opus") item = album.items()[0] def analyse(target_level): self.config["replaygain"]["r128_targetlevel"] = target_level self.run_command("replaygain", "-f") item.load() return item.r128_track_gain gain_relative_to_84 = analyse(84) gain_relative_to_89 = analyse(89) assert gain_relative_to_84 != gain_relative_to_89 def test_per_disc(self): # Use the per_disc option and add a little more concurrency. album = self._add_album(track_count=4, disc_count=3) self.config["replaygain"]["per_disc"] = True self.run_command("replaygain", "-a") # FIXME: Add fixtures with known track/album gain (within a suitable # tolerance) so that we can actually check per-disc operation here. for item in album.items(): assert item.rg_track_gain is not None assert item.rg_album_gain is not None @unittest.skipIf(not GST_AVAILABLE, "gstreamer cannot be found") class ReplayGainGstCliTest( ReplayGainCliTest, ReplayGainTestCase, GstBackendMixin ): FNAME = "full" # file contains only silence @unittest.skipIf(not GAIN_PROG, "no *gain command found") class ReplayGainCmdCliTest( ReplayGainCliTest, ReplayGainTestCase, CmdBackendMixin ): FNAME = "full" # file contains only silence @unittest.skipIf(not FFMPEG_AVAILABLE, "ffmpeg cannot be found") class ReplayGainFfmpegCliTest( ReplayGainCliTest, ReplayGainTestCase, FfmpegBackendMixin ): FNAME = "full" # file contains only silence @unittest.skipIf(not FFMPEG_AVAILABLE, "ffmpeg cannot be found") class ReplayGainFfmpegNoiseCliTest( ReplayGainCliTest, ReplayGainTestCase, FfmpegBackendMixin ): FNAME = "whitenoise" class ImportTest(AsIsImporterMixin): def test_import_converted(self): self.run_asis_importer() for item in self.lib.items(): # FIXME: Add fixtures with known track/album gain (within a # suitable tolerance) so that we can actually check correct # operation here. assert item.rg_track_gain is not None assert item.rg_album_gain is not None @unittest.skipIf(not GST_AVAILABLE, "gstreamer cannot be found") class ReplayGainGstImportTest(ImportTest, ReplayGainTestCase, GstBackendMixin): pass @unittest.skipIf(not GAIN_PROG, "no *gain command found") class ReplayGainCmdImportTest(ImportTest, ReplayGainTestCase, CmdBackendMixin): pass @unittest.skipIf(not FFMPEG_AVAILABLE, "ffmpeg cannot be found") class ReplayGainFfmpegImportTest( ImportTest, ReplayGainTestCase, FfmpegBackendMixin ): pass @unittest.skipIf(not FFMPEG_AVAILABLE, "ffmpeg cannot be found") class ReplayGainFfmpegThreadedImportTest( ThreadedImportMixin, ImportTest, ReplayGainTestCase, FfmpegBackendMixin ): pass ================================================ FILE: test/plugins/test_scrub.py ================================================ import os from mediafile import MediaFile from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin class ScrubbedImportTest(AsIsImporterMixin, PluginMixin, ImportTestCase): db_on_disk = True plugin = "scrub" def test_tags_not_scrubbed(self): with self.configure_plugin({"auto": False}): self.run_asis_importer(write=True) for item in self.lib.items(): imported_file = MediaFile(os.path.join(item.path)) assert imported_file.artist == "Tag Artist" assert imported_file.album == "Tag Album" def test_tags_restored(self): with self.configure_plugin({"auto": True}): self.run_asis_importer(write=True) for item in self.lib.items(): imported_file = MediaFile(os.path.join(item.path)) assert imported_file.artist == "Tag Artist" assert imported_file.album == "Tag Album" def test_tags_not_restored(self): with self.configure_plugin({"auto": True}): self.run_asis_importer(write=False) for item in self.lib.items(): imported_file = MediaFile(os.path.join(item.path)) assert imported_file.artist is None assert imported_file.album is None ================================================ FILE: test/plugins/test_smartplaylist.py ================================================ # This file is part of beets. # Copyright 2016, Bruno Cauet. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # TODO: Tests in this fire are very bad. Stop using Mocks in this module. from os import path, remove from pathlib import Path from shutil import rmtree from tempfile import mkdtemp from unittest.mock import MagicMock, Mock, PropertyMock import pytest from beets import config from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort from beets.library import Album, Item, parse_query_string from beets.test.helper import BeetsTestCase, IOMixin, PluginTestCase from beets.ui import UserError from beets.util import CHAR_REPLACE, syspath from beetsplug.smartplaylist import SmartPlaylistPlugin class SmartPlaylistTest(BeetsTestCase): def test_build_queries(self): spl = SmartPlaylistPlugin() assert spl._matched_playlists == set() assert spl._unmatched_playlists == set() config["smartplaylist"]["playlists"].set([]) spl.build_queries() assert spl._matched_playlists == set() assert spl._unmatched_playlists == set() config["smartplaylist"]["playlists"].set( [ {"name": "foo", "query": "FOO foo"}, {"name": "bar", "album_query": ["BAR bar1", "BAR bar2"]}, {"name": "baz", "query": "BAZ baz", "album_query": "BAZ baz"}, ] ) spl.build_queries() assert spl._matched_playlists == set() foo_foo = parse_query_string("FOO foo", Item) baz_baz = parse_query_string("BAZ baz", Item) baz_baz2 = parse_query_string("BAZ baz", Album) # Multiple queries are now stored as a tuple of (query, sort) tuples bar_queries = tuple( [ parse_query_string("BAR bar1", Album), parse_query_string("BAR bar2", Album), ] ) assert spl._unmatched_playlists == { ("foo", foo_foo, (None, None)), ("baz", baz_baz, baz_baz2), ("bar", (None, None), (bar_queries, None)), } def test_build_queries_with_sorts(self): spl = SmartPlaylistPlugin() config["smartplaylist"]["playlists"].set( [ {"name": "no_sort", "query": "foo"}, {"name": "one_sort", "query": "foo year+"}, {"name": "only_empty_sorts", "query": ["foo", "bar"]}, {"name": "one_non_empty_sort", "query": ["foo year+", "bar"]}, { "name": "multiple_sorts", "query": ["foo year+", "bar genres-"], }, { "name": "mixed", "query": ["foo year+", "bar", "baz genres+ id-"], }, ] ) spl.build_queries() # Multiple queries now return a tuple of (query, sort) tuples, not combined sorts = {} for name, (query_data, sort), _ in spl._unmatched_playlists: if isinstance(query_data, tuple): # Tuple of queries - each has its own sort sorts[name] = [s for _, s in query_data] else: sorts[name] = sort sort = FixedFieldSort # short cut since we're only dealing with this assert sorts["no_sort"] == NullSort() assert sorts["one_sort"] == sort("year") # Multiple queries store individual sorts in the tuple assert all(isinstance(x, NullSort) for x in sorts["only_empty_sorts"]) assert sorts["one_non_empty_sort"] == [sort("year"), NullSort()] assert sorts["multiple_sorts"] == [sort("year"), sort("genres", False)] assert sorts["mixed"] == [ sort("year"), NullSort(), MultipleSort([sort("genres"), sort("id", False)]), ] def test_matches(self): spl = SmartPlaylistPlugin() a = MagicMock(Album) i = MagicMock(Item) assert not spl.matches(i, None, None) assert not spl.matches(a, None, None) query = Mock() query.match.side_effect = {i: True}.__getitem__ assert spl.matches(i, query, None) assert not spl.matches(a, query, None) a_query = Mock() a_query.match.side_effect = {a: True}.__getitem__ assert not spl.matches(i, None, a_query) assert spl.matches(a, None, a_query) assert spl.matches(i, query, a_query) assert spl.matches(a, query, a_query) # Test with list of queries q1 = Mock() q1.match.return_value = False q2 = Mock() q2.match.side_effect = {i: True}.__getitem__ queries_list = [(q1, None), (q2, None)] assert spl.matches(i, queries_list, None) assert not spl.matches(a, queries_list, None) def test_db_changes(self): spl = SmartPlaylistPlugin() nones = None, None pl1 = "1", ("q1", None), nones pl2 = "2", ("q2", None), nones pl3 = "3", ("q3", None), nones spl._unmatched_playlists = {pl1, pl2, pl3} spl._matched_playlists = set() spl.matches = Mock(return_value=False) spl.db_change(None, "nothing") assert spl._unmatched_playlists == {pl1, pl2, pl3} assert spl._matched_playlists == set() spl.matches.side_effect = lambda _, q, __: q == "q3" spl.db_change(None, "matches 3") assert spl._unmatched_playlists == {pl1, pl2} assert spl._matched_playlists == {pl3} spl.matches.side_effect = lambda _, q, __: q == "q1" spl.db_change(None, "matches 3") assert spl._matched_playlists == {pl1, pl3} assert spl._unmatched_playlists == {pl2} def test_playlist_update(self): spl = SmartPlaylistPlugin() i = Mock(path=b"/tagada.mp3") i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", b"ta:ga:da" ).decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) assert content == b"/tagada.mp3\n" def test_playlist_update_output_extm3u(self): spl = SmartPlaylistPlugin() i = MagicMock() type(i).artist = PropertyMock(return_value="fake artist") type(i).title = PropertyMock(return_value="fake title") type(i).length = PropertyMock(return_value=300.123) type(i).path = PropertyMock(return_value=b"/tagada.mp3") i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", b"ta:ga:da", ).decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["prefix"] = "http://beets:8337/files" config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) assert content == ( b"#EXTM3U\n" b"#EXTINF:300,fake artist - fake title\n" b"http://beets:8337/files/tagada.mp3\n" ) def test_playlist_update_output_extm3u_fields(self): spl = SmartPlaylistPlugin() i = MagicMock() type(i).artist = PropertyMock(return_value="Fake Artist") type(i).title = PropertyMock(return_value="fake Title") type(i).length = PropertyMock(return_value=300.123) type(i).path = PropertyMock(return_value=b"/tagada.mp3") a = {"id": 456, "genres": ["Rock", "Pop"]} i.__getitem__.side_effect = a.__getitem__ i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", b"ta:ga:da", ).decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) config["smartplaylist"]["fields"] = ["id", "genres"] try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) assert content == ( b"#EXTM3U\n" b'#EXTINF:300 id="456" genres="Rock%3B%20Pop",Fake Artist - fake Title\n' b"/tagada.mp3\n" ) def test_playlist_update_uri_format(self): spl = SmartPlaylistPlugin() i = MagicMock() type(i).id = PropertyMock(return_value=3) type(i).path = PropertyMock(return_value=b"/tagada.mp3") i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", b"ta:ga:da" ).decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) spl._matched_playlists = {pl} dir = mkdtemp() tpl = "http://beets:8337/item/$id/file" config["smartplaylist"]["uri_format"] = tpl config["smartplaylist"]["playlist_dir"] = dir # The following options should be ignored when uri_format is set config["smartplaylist"]["relative_to"] = "/data" config["smartplaylist"]["prefix"] = "/prefix" config["smartplaylist"]["urlencode"] = True try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) assert content == b"http://beets:8337/item/3/file\n" def test_playlist_update_multiple_queries_preserve_order(self): """Test that multiple queries preserve their order in the playlist.""" spl = SmartPlaylistPlugin() # Create three mock items i1 = Mock(path=b"/item1.mp3", id=1) i1.evaluate_template.return_value = "ordered.m3u" i2 = Mock(path=b"/item2.mp3", id=2) i2.evaluate_template.return_value = "ordered.m3u" i3 = Mock(path=b"/item3.mp3", id=3) i3.evaluate_template.return_value = "ordered.m3u" lib = Mock() lib.replacements = CHAR_REPLACE lib.albums.return_value = [] # Set up lib.items to return different items for different queries q1 = Mock() q2 = Mock() q3 = Mock() def items_side_effect(query, sort): if query == q1: return [i1] elif query == q2: return [i2] elif query == q3: return [i3] return [] lib.items.side_effect = items_side_effect # Create playlist with multiple queries (stored as tuple) queries_and_sorts = ((q1, None), (q2, None), (q3, None)) pl = "ordered.m3u", (queries_and_sorts, None), (None, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise # Verify that lib.items was called with queries in the correct order assert lib.items.call_count == 3 lib.items.assert_any_call(q1, None) lib.items.assert_any_call(q2, None) lib.items.assert_any_call(q3, None) m3u_filepath = Path(dir, "ordered.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) # Items should be in order: i1, i2, i3 assert content == b"/item1.mp3\n/item2.mp3\n/item3.mp3\n" def test_playlist_update_multiple_queries_no_duplicates(self): """Test that items matching multiple queries only appear once.""" spl = SmartPlaylistPlugin() # Create two mock items i1 = Mock(path=b"/item1.mp3", id=1) i1.evaluate_template.return_value = "dedup.m3u" i2 = Mock(path=b"/item2.mp3", id=2) i2.evaluate_template.return_value = "dedup.m3u" lib = Mock() lib.replacements = CHAR_REPLACE lib.albums.return_value = [] # Set up lib.items so both queries return overlapping items q1 = Mock() q2 = Mock() def items_side_effect(query, sort): if query == q1: return [i1, i2] # Both items match q1 elif query == q2: return [i2] # Only i2 matches q2 return [] lib.items.side_effect = items_side_effect # Create playlist with multiple queries (stored as tuple) queries_and_sorts = ((q1, None), (q2, None)) pl = "dedup.m3u", (queries_and_sorts, None), (None, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise m3u_filepath = Path(dir, "dedup.m3u") assert m3u_filepath.exists() content = m3u_filepath.read_bytes() rmtree(syspath(dir)) # i2 should only appear once even though it matches both queries # Order should be: i1 (from q1), i2 (from q1, skipped in q2) assert content == b"/item1.mp3\n/item2.mp3\n" # Verify i2 is not duplicated assert content.count(b"/item2.mp3") == 1 def test_playlist_update_dest_regen(self): spl = SmartPlaylistPlugin() i = MagicMock() type(i).artist = PropertyMock(return_value="fake artist") type(i).title = PropertyMock(return_value="fake title") type(i).length = PropertyMock(return_value=300.123) # Set a path which is not equal to the one returned by `item.destination`. type(i).path = PropertyMock( return_value=b"/imported/path/with/dont/move/tagada.mp3" ) # Set a path which would be equal to the one returned by `item.destination`. type(i).destination = PropertyMock(return_value=lambda: b"/tagada.mp3") i.evaluate_template.side_effect = lambda pl, _: pl.replace( b"$title", b"ta:ga:da", ).decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None) spl._matched_playlists = {pl} dir = mkdtemp() config["smartplaylist"]["output"] = "extm3u" config["smartplaylist"]["prefix"] = "http://beets:8337/files" config["smartplaylist"]["relative_to"] = False config["smartplaylist"]["playlist_dir"] = str(dir) # Test when `dest_regen` is set to True: # Intended behavior is to use the path of `i.destination`. config["smartplaylist"]["dest_regen"] = True try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() with open(syspath(m3u_filepath), "rb") as f: content = f.read() rmtree(syspath(dir)) assert content == ( b"#EXTM3U\n" b"#EXTINF:300,fake artist - fake title\n" b"http://beets:8337/files/tagada.mp3\n" ) # Test when `dest_regen` is set to False: # Intended behavior is to use the path of `i.path`. config["smartplaylist"]["dest_regen"] = False try: spl.update_playlists(lib) except Exception: rmtree(syspath(dir)) raise m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u") assert m3u_filepath.exists() with open(syspath(m3u_filepath), "rb") as f: content = f.read() rmtree(syspath(dir)) assert content == ( b"#EXTM3U\n" b"#EXTINF:300,fake artist - fake title\n" b"http://beets:8337/files/imported/path/with/dont/move/tagada.mp3\n" ) class SmartPlaylistCLITest(IOMixin, PluginTestCase): plugin = "smartplaylist" def setUp(self): super().setUp() self.item = self.add_item() config["smartplaylist"]["playlists"].set( [ {"name": "my_playlist.m3u", "query": self.item.title}, {"name": "all.m3u", "query": ""}, ] ) config["smartplaylist"]["playlist_dir"].set(str(self.temp_dir_path)) def test_splupdate(self): with pytest.raises(UserError): self.run_with_output("splupdate", "tagada") self.run_with_output("splupdate", "my_playlist") m3u_path = self.temp_dir_path / "my_playlist.m3u" assert m3u_path.exists() assert m3u_path.read_bytes() == self.item.path + b"\n" remove(syspath(m3u_path)) self.run_with_output("splupdate", "my_playlist.m3u") assert m3u_path.read_bytes() == self.item.path + b"\n" remove(syspath(m3u_path)) self.run_with_output("splupdate") for name in (b"my_playlist.m3u", b"all.m3u"): with open(path.join(self.temp_dir, name), "rb") as f: assert f.read() == self.item.path + b"\n" ================================================ FILE: test/plugins/test_spotify.py ================================================ """Tests for the 'spotify' plugin""" import os from urllib.parse import parse_qs, urlparse import responses from beets.library import Item from beets.test import _common from beets.test.helper import PluginTestCase from beetsplug import spotify class ArgumentsMock: def __init__(self, mode, show_failures): self.mode = mode self.show_failures = show_failures self.verbose = 1 def _params(url): """Get the query parameters from a URL.""" return parse_qs(urlparse(url).query) class SpotifyPluginTest(PluginTestCase): plugin = "spotify" @responses.activate def setUp(self): responses.add( responses.POST, spotify.SpotifyPlugin.oauth_token_url, status=200, json={ "access_token": "3XyiC3raJySbIAV5LVYj1DaWbcocNi3LAJTNXRnYY" "GVUl6mbbqXNhW3YcZnQgYXNWHFkVGSMlc0tMuvq8CF", "token_type": "Bearer", "expires_in": 3600, "scope": "", }, ) super().setUp() self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) self.spotify._parse_opts(opts) def test_args(self): opts = ArgumentsMock("fail", True) assert not self.spotify._parse_opts(opts) opts = ArgumentsMock("list", False) assert self.spotify._parse_opts(opts) def test_empty_query(self): assert self.spotify._match_library_tracks(self.lib, "1=2") is None @responses.activate def test_missing_request(self): json_file = os.path.join( _common.RSRC, b"spotify", b"missing_request.json" ) with open(json_file, "rb") as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type="application/json", ) item = Item( mb_trackid="01234", album="lkajsdflakjsd", albumartist="ujydfsuihse", title="duifhjslkef", length=10, ) item.add(self.lib) assert [] == self.spotify._match_library_tracks(self.lib, "") params = _params(responses.calls[0].request.url) query = params["q"][0] assert "duifhjslkef" in query assert "artist:'ujydfsuihse'" in query assert "album:'lkajsdflakjsd'" in query assert params["type"] == ["track"] @responses.activate def test_track_request(self): json_file = os.path.join( _common.RSRC, b"spotify", b"track_request.json" ) with open(json_file, "rb") as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type="application/json", ) item = Item( mb_trackid="01234", album="Despicable Me 2", albumartist="Pharrell Williams", title="Happy", length=10, ) item.add(self.lib) results = self.spotify._match_library_tracks(self.lib, "Happy") assert 1 == len(results) assert "6NPVjNh8Jhru9xOmyQigds" == results[0]["id"] self.spotify._output_match_results(results) params = _params(responses.calls[0].request.url) query = params["q"][0] assert "Happy" in query assert "artist:'Pharrell Williams'" in query assert "album:'Despicable Me 2'" in query assert params["type"] == ["track"] @responses.activate def test_track_for_id(self): """Tests if plugin is able to fetch a track by its Spotify ID""" # Mock the Spotify 'Get Track' call json_file = os.path.join(_common.RSRC, b"spotify", b"track_info.json") with open(json_file, "rb") as f: response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.track_url}6NPVjNh8Jhru9xOmyQigds", body=response_body, status=200, content_type="application/json", ) # Mock the Spotify 'Get Album' call json_file = os.path.join(_common.RSRC, b"spotify", b"album_info.json") with open(json_file, "rb") as f: response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.album_url}5l3zEmMrOhOzG8d8s83GOL", body=response_body, status=200, content_type="application/json", ) # Mock the Spotify 'Search' call json_file = os.path.join( _common.RSRC, b"spotify", b"track_request.json" ) with open(json_file, "rb") as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type="application/json", ) track_info = self.spotify.track_for_id("6NPVjNh8Jhru9xOmyQigds") item = Item( mb_trackid=track_info.track_id, albumartist=track_info.artist, title=track_info.title, length=track_info.length, ) item.add(self.lib) results = self.spotify._match_library_tracks(self.lib, "Happy") assert 1 == len(results) assert "6NPVjNh8Jhru9xOmyQigds" == results[0]["id"] @responses.activate def test_japanese_track(self): """Ensure non-ASCII characters remain unchanged in search queries""" # Path to the mock JSON file for the Japanese track json_file = os.path.join( _common.RSRC, b"spotify", b"japanese_track_request.json" ) # Load the mock JSON response with open(json_file, "rb") as f: response_body = f.read() # Mock Spotify Search API response responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type="application/json", ) # Create a mock item with Japanese metadata item = Item( mb_trackid="56789", album="盗作", albumartist="ヨルシカ", title="思想犯", length=10, ) item.add(self.lib) # Search without ascii encoding with self.configure_plugin( { "search_query_ascii": False, } ): assert self.spotify.config["search_query_ascii"].get() is False # Call the method to match library tracks results = self.spotify._match_library_tracks(self.lib, item.title) # Assertions to verify results assert results is not None assert 1 == len(results) assert results[0]["name"] == item.title assert results[0]["artists"][0]["name"] == item.albumartist assert results[0]["album"]["name"] == item.album # Verify search query parameters params = _params(responses.calls[0].request.url) query = params["q"][0] assert item.title in query assert f"artist:'{item.albumartist}'" in query assert f"album:'{item.album}'" in query assert not query.isascii() # Is not found in the library if ascii encoding is enabled with self.configure_plugin( { "search_query_ascii": True, } ): assert self.spotify.config["search_query_ascii"].get() is True results = self.spotify._match_library_tracks(self.lib, item.title) params = _params(responses.calls[1].request.url) query = params["q"][0] assert query.isascii() @responses.activate def test_multiartist_album_and_track(self): """Tests if plugin is able to map multiple artists in an album and track info correctly""" # Mock the Spotify 'Get Album' call json_file = os.path.join( _common.RSRC, b"spotify", b"multiartist_album.json" ) with open(json_file, "rb") as f: album_response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.album_url}0yhKyyjyKXWUieJ4w1IAEa", body=album_response_body, status=200, content_type="application/json", ) # Mock the Spotify 'Get Track' call json_file = os.path.join( _common.RSRC, b"spotify", b"multiartist_track.json" ) with open(json_file, "rb") as f: track_response_body = f.read() responses.add( responses.GET, f"{spotify.SpotifyPlugin.track_url}6sjZfVJworBX6TqyjkxIJ1", body=track_response_body, status=200, content_type="application/json", ) album_info = self.spotify.album_for_id("0yhKyyjyKXWUieJ4w1IAEa") assert album_info is not None assert album_info.artist == "Project Skylate, Sugar Shrill" assert album_info.artists == ["Project Skylate", "Sugar Shrill"] assert album_info.artist_id == "6m8MRXIVKb6wQaPlBIDMr1" assert album_info.artists_ids == [ "6m8MRXIVKb6wQaPlBIDMr1", "4kkAIoQmNT5xEoNH5BuQLe", ] assert len(album_info.tracks) == 1 assert album_info.tracks[0].artist == "Foo, Bar" assert album_info.tracks[0].artists == ["Foo", "Bar"] assert album_info.tracks[0].artist_id == "12345" assert album_info.tracks[0].artists_ids == ["12345", "67890"] track_info = self.spotify.track_for_id("6sjZfVJworBX6TqyjkxIJ1") assert track_info is not None assert track_info.artist == "Foo, Bar" assert track_info.artists == ["Foo", "Bar"] assert track_info.artist_id == "12345" assert track_info.artists_ids == ["12345", "67890"] ================================================ FILE: test/plugins/test_subsonicupdate.py ================================================ """Tests for the 'subsonic' plugin.""" import unittest from urllib.parse import parse_qs, urlparse import responses from beets import config from beetsplug import subsonicupdate class ArgumentsMock: """Argument mocks for tests.""" def __init__(self, mode, show_failures): """Constructs ArgumentsMock.""" self.mode = mode self.show_failures = show_failures self.verbose = 1 def _params(url): """Get the query parameters from a URL.""" return parse_qs(urlparse(url).query) class SubsonicPluginTest(unittest.TestCase): """Test class for subsonicupdate.""" @responses.activate def setUp(self): """Sets up config and plugin for test.""" super().setUp() config["subsonic"]["user"] = "admin" config["subsonic"]["pass"] = "admin" config["subsonic"]["url"] = "http://localhost:4040" responses.add( responses.GET, "http://localhost:4040/rest/ping.view", status=200, body=self.PING_BODY, ) self.subsonicupdate = subsonicupdate.SubsonicUpdate() PING_BODY = """ { "subsonic-response": { "status": "failed", "version": "1.15.0" } } """ SUCCESS_BODY = """ { "subsonic-response": { "status": "ok", "version": "1.15.0", "scanStatus": { "scanning": true, "count": 1000 } } } """ FAILED_BODY = """ { "subsonic-response": { "status": "failed", "version": "1.15.0", "error": { "code": 40, "message": "Wrong username or password." } } } """ ERROR_BODY = """ { "timestamp": 1599185854498, "status": 404, "error": "Not Found", "message": "No message available", "path": "/rest/startScn" } """ @responses.activate def test_start_scan(self): """Tests success path based on best case scenario.""" responses.add( responses.GET, "http://localhost:4040/rest/startScan", status=200, body=self.SUCCESS_BODY, ) self.subsonicupdate.start_scan() @responses.activate def test_start_scan_failed_bad_credentials(self): """Tests failed path based on bad credentials.""" responses.add( responses.GET, "http://localhost:4040/rest/startScan", status=200, body=self.FAILED_BODY, ) self.subsonicupdate.start_scan() @responses.activate def test_start_scan_failed_not_found(self): """Tests failed path based on resource not found.""" responses.add( responses.GET, "http://localhost:4040/rest/startScan", status=404, body=self.ERROR_BODY, ) self.subsonicupdate.start_scan() def test_start_scan_failed_unreachable(self): """Tests failed path based on service not available.""" self.subsonicupdate.start_scan() @responses.activate def test_url_with_context_path(self): """Tests success for included with contextPath.""" config["subsonic"]["url"] = "http://localhost:4040/contextPath/" responses.add( responses.GET, "http://localhost:4040/contextPath/rest/startScan", status=200, body=self.SUCCESS_BODY, ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_trailing_forward_slash_url(self): """Tests success path based on trailing forward slash.""" config["subsonic"]["url"] = "http://localhost:4040/" responses.add( responses.GET, "http://localhost:4040/rest/startScan", status=200, body=self.SUCCESS_BODY, ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_missing_port(self): """Tests failed path based on missing port.""" config["subsonic"]["url"] = "http://localhost/airsonic" responses.add( responses.GET, "http://localhost/airsonic/rest/startScan", status=200, body=self.SUCCESS_BODY, ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_missing_schema(self): """Tests failed path based on missing schema.""" config["subsonic"]["url"] = "localhost:4040/airsonic" responses.add( responses.GET, "http://localhost:4040/rest/startScan", status=200, body=self.SUCCESS_BODY, ) self.subsonicupdate.start_scan() ================================================ FILE: test/plugins/test_substitute.py ================================================ # This file is part of beets. # Copyright 2024, Nicholas Boyd Isacsson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the substitute plugin regex functionality.""" from beets.test.helper import PluginTestCase from beetsplug.substitute import Substitute class SubstitutePluginTest(PluginTestCase): plugin = "substitute" preload_plugin = False def run_substitute(self, config, cases): with self.configure_plugin(config): for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected def test_simple_substitute(self): self.run_substitute( { "a": "x", "b": "y", "c": "z", }, [("a", "x"), ("b", "y"), ("c", "z")], ) def test_case_insensitivity(self): self.run_substitute({"a": "x"}, [("A", "x")]) def test_unmatched_input_preserved(self): self.run_substitute({"a": "x"}, [("c", "c")]) def test_regex_to_static(self): self.run_substitute( {".*jimi hendrix.*": "Jimi Hendrix"}, [("The Jimi Hendrix Experience", "Jimi Hendrix")], ) def test_regex_capture_group(self): self.run_substitute( {"^(.*?)(,| &| and).*": r"\1"}, [ ("King Creosote & Jon Hopkins", "King Creosote"), ( ( "Michael Hurley, The Holy Modal Rounders, Jeffrey" " Frederick & The Clamtones" ), "Michael Hurley", ), ("James Yorkston and the Athletes", "James Yorkston"), ], ) def test_partial_substitution(self): self.run_substitute({r"\.": ""}, [("U.N.P.O.C.", "UNPOC")]) def test_rules_applied_in_definition_order(self): self.run_substitute( { "a": "x", "[ab]": "y", "b": "z", }, [ ("a", "x"), ("b", "y"), ], ) def test_rules_applied_in_sequence(self): self.run_substitute( {"a": "b", "b": "c", "d": "a"}, [ ("a", "c"), ("b", "c"), ("d", "a"), ], ) ================================================ FILE: test/plugins/test_the.py ================================================ """Tests for the 'the' plugin""" import unittest from beets import config from beetsplug.the import FORMAT, PATTERN_A, PATTERN_THE, ThePlugin class ThePluginTest(unittest.TestCase): def test_unthe_with_default_patterns(self): assert ThePlugin().unthe("", PATTERN_THE) == "" assert ( ThePlugin().unthe("The Something", PATTERN_THE) == "Something, The" ) assert ThePlugin().unthe("The The", PATTERN_THE) == "The, The" assert ThePlugin().unthe("The The", PATTERN_THE) == "The, The" assert ThePlugin().unthe("The The X", PATTERN_THE) == "The X, The" assert ThePlugin().unthe("the The", PATTERN_THE) == "The, the" assert ( ThePlugin().unthe("Protected The", PATTERN_THE) == "Protected The" ) assert ThePlugin().unthe("A Boy", PATTERN_A) == "Boy, A" assert ThePlugin().unthe("a girl", PATTERN_A) == "girl, a" assert ThePlugin().unthe("An Apple", PATTERN_A) == "Apple, An" assert ThePlugin().unthe("An A Thing", PATTERN_A) == "A Thing, An" assert ThePlugin().unthe("the An Arse", PATTERN_A) == "the An Arse" assert ( ThePlugin().unthe("TET - Travailleur", PATTERN_THE) == "TET - Travailleur" ) def test_unthe_with_strip(self): config["the"]["strip"] = True assert ThePlugin().unthe("The Something", PATTERN_THE) == "Something" assert ThePlugin().unthe("An A", PATTERN_A) == "A" def test_template_function_with_defaults(self): ThePlugin().patterns = [PATTERN_THE, PATTERN_A] assert ThePlugin().the_template_func("The The") == "The, The" assert ThePlugin().the_template_func("An A") == "A, An" def test_custom_pattern(self): config["the"]["patterns"] = ["^test\\s"] config["the"]["format"] = FORMAT assert ThePlugin().the_template_func("test passed") == "passed, test" def test_custom_format(self): config["the"]["patterns"] = [PATTERN_THE, PATTERN_A] config["the"]["format"] = "{1} ({0})" assert ThePlugin().the_template_func("The A") == "The (A)" ================================================ FILE: test/plugins/test_thumbnails.py ================================================ # This file is part of beets. # Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os.path from shutil import rmtree from tempfile import mkdtemp from unittest.mock import Mock, call, patch import pytest from beets.test.helper import BeetsTestCase from beets.util import bytestring_path, syspath from beetsplug.thumbnails import ( LARGE_DIR, NORMAL_DIR, GioURI, PathlibURI, ThumbnailsPlugin, ) class ThumbnailsTest(BeetsTestCase): @patch("beetsplug.thumbnails.ArtResizer") @patch("beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok", Mock()) @patch("beetsplug.thumbnails.os.stat") def test_add_tags(self, mock_stat, mock_artresizer): plugin = ThumbnailsPlugin() plugin.get_uri = Mock( side_effect={b"/path/to/cover": "COVER_URI"}.__getitem__ ) album = Mock(artpath=b"/path/to/cover") mock_stat.return_value.st_mtime = 12345 plugin.add_tags(album, b"/path/to/thumbnail") metadata = {"Thumb::URI": "COVER_URI", "Thumb::MTime": "12345"} mock_artresizer.shared.write_metadata.assert_called_once_with( b"/path/to/thumbnail", metadata, ) mock_stat.assert_called_once_with(syspath(album.artpath)) @patch("beetsplug.thumbnails.os") @patch("beetsplug.thumbnails.ArtResizer") @patch("beetsplug.thumbnails.GioURI") def test_check_local_ok(self, mock_giouri, mock_artresizer, mock_os): # test local resizing capability mock_artresizer.shared.local = False mock_artresizer.shared.can_write_metadata = False plugin = ThumbnailsPlugin() assert not plugin._check_local_ok() # test dirs creation mock_artresizer.shared.local = True mock_artresizer.shared.can_write_metadata = True def exists(path): if path == syspath(NORMAL_DIR): return False if path == syspath(LARGE_DIR): return True raise ValueError(f"unexpected path {path!r}") mock_os.path.exists = exists plugin = ThumbnailsPlugin() mock_os.makedirs.assert_called_once_with(syspath(NORMAL_DIR)) assert plugin._check_local_ok() # test metadata writer function mock_os.path.exists = lambda _: True mock_artresizer.shared.local = True mock_artresizer.shared.can_write_metadata = False with pytest.raises(RuntimeError): ThumbnailsPlugin() mock_artresizer.shared.local = True mock_artresizer.shared.can_write_metadata = True assert ThumbnailsPlugin()._check_local_ok() # test URI getter function giouri_inst = mock_giouri.return_value giouri_inst.available = True assert ThumbnailsPlugin().get_uri == giouri_inst.uri giouri_inst.available = False assert ThumbnailsPlugin().get_uri.__self__.__class__ == PathlibURI @patch("beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok", Mock()) @patch("beetsplug.thumbnails.ArtResizer") @patch("beets.util.syspath", Mock(side_effect=lambda x: x)) @patch("beetsplug.thumbnails.os") @patch("beetsplug.thumbnails.shutil") def test_make_cover_thumbnail(self, mock_shutils, mock_os, mock_artresizer): thumbnail_dir = os.path.normpath(b"/thumbnail/dir") md5_file = os.path.join(thumbnail_dir, b"md5") path_to_art = os.path.normpath(b"/path/to/art") path_to_resized_art = os.path.normpath(b"/path/to/resized/artwork") mock_os.path.join = os.path.join # don't mock that function plugin = ThumbnailsPlugin() plugin.add_tags = Mock() album = Mock(artpath=path_to_art) plugin.thumbnail_file_name = Mock(return_value=b"md5") mock_os.path.exists.return_value = False def os_stat(target): if target == syspath(md5_file): return Mock(st_mtime=1) elif target == syspath(path_to_art): return Mock(st_mtime=2) else: raise ValueError(f"invalid target {target}") mock_os.stat.side_effect = os_stat mock_resize = mock_artresizer.shared.resize mock_resize.return_value = path_to_resized_art plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) mock_os.path.exists.assert_called_once_with(syspath(md5_file)) mock_resize.assert_called_once_with(12345, path_to_art, md5_file) plugin.add_tags.assert_called_once_with(album, path_to_resized_art) mock_shutils.move.assert_called_once_with( syspath(path_to_resized_art), syspath(md5_file) ) # now test with recent thumbnail & with force mock_os.path.exists.return_value = True plugin.force = False mock_resize.reset_mock() def os_stat(target): if target == syspath(md5_file): return Mock(st_mtime=3) elif target == syspath(path_to_art): return Mock(st_mtime=2) else: raise ValueError(f"invalid target {target}") mock_os.stat.side_effect = os_stat plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) assert mock_resize.call_count == 0 # and with force plugin.config["force"] = True plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) mock_resize.assert_called_once_with(12345, path_to_art, md5_file) @patch("beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok", Mock()) def test_make_dolphin_cover_thumbnail(self): plugin = ThumbnailsPlugin() tmp = bytestring_path(mkdtemp()) album = Mock(path=tmp, artpath=os.path.join(tmp, b"cover.jpg")) plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: assert f.read().splitlines() == [ b"[Desktop Entry]", b"Icon=./cover.jpg", ] # not rewritten when it already exists (yup that's a big limitation) album.artpath = b"/my/awesome/art.tiff" plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: assert f.read().splitlines() == [ b"[Desktop Entry]", b"Icon=./cover.jpg", ] rmtree(syspath(tmp)) @patch("beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok", Mock()) @patch("beetsplug.thumbnails.ArtResizer") def test_process_album(self, mock_artresizer): get_size = mock_artresizer.shared.get_size plugin = ThumbnailsPlugin() make_cover = plugin.make_cover_thumbnail = Mock(return_value=True) make_dolphin = plugin.make_dolphin_cover_thumbnail = Mock() # no art album = Mock(artpath=None) plugin.process_album(album) assert get_size.call_count == 0 assert make_dolphin.call_count == 0 # cannot get art size album.artpath = b"/path/to/art" get_size.return_value = None plugin.process_album(album) get_size.assert_called_once_with(b"/path/to/art") assert make_cover.call_count == 0 # dolphin tests plugin.config["dolphin"] = False plugin.process_album(album) assert make_dolphin.call_count == 0 plugin.config["dolphin"] = True plugin.process_album(album) make_dolphin.assert_called_once_with(album) # small art get_size.return_value = 200, 200 plugin.process_album(album) make_cover.assert_called_once_with(album, 128, NORMAL_DIR) # big art make_cover.reset_mock() get_size.return_value = 500, 500 plugin.process_album(album) make_cover.assert_has_calls( [call(album, 128, NORMAL_DIR), call(album, 256, LARGE_DIR)], any_order=True, ) @patch("beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok", Mock()) def test_invokations(self): plugin = ThumbnailsPlugin() plugin.process_album = Mock() album = Mock() plugin.process_album.reset_mock() lib = Mock() album2 = Mock() lib.albums.return_value = [album, album2] plugin.process_query(lib, Mock(), None) plugin.process_album.assert_has_calls( [call(album), call(album2)], any_order=True ) @patch("beetsplug.thumbnails.BaseDirectory") def test_thumbnail_file_name(self, mock_basedir): plug = ThumbnailsPlugin() plug.get_uri = Mock(return_value="file:///my/uri") assert ( plug.thumbnail_file_name(b"idontcare") == b"9488f5797fbe12ffb316d607dfd93d04.png" ) def test_uri(self): gio = GioURI() if not gio.available: self.skipTest("GIO library not found") import ctypes with pytest.raises(ctypes.ArgumentError): gio.uri("/foo") assert gio.uri(b"/foo") == "file:///foo" assert gio.uri(b"/foo!") == "file:///foo!" assert ( gio.uri(b"/music/\xec\x8b\xb8\xec\x9d\xb4") == "file:///music/%EC%8B%B8%EC%9D%B4" ) class TestPathlibURI: """Test PathlibURI class""" def test_uri(self): test_uri = PathlibURI() # test it won't break if we pass it bytes for a path test_uri.uri(b"/") ================================================ FILE: test/plugins/test_titlecase.py ================================================ # This file is part of beets. # Copyright 2025, Henry Oberholtzer # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'titlecase' plugin""" from unittest.mock import patch from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.importer import ImportSession, ImportTask from beets.library import Item from beets.test.helper import PluginTestCase from beetsplug.titlecase import TitlecasePlugin titlecase_fields_testcases = [ ( { "fields": [ "artist", "albumartist", "title", "album", "mb_albumd", "year", ], "force_lowercase": True, }, Item( artist="OPHIDIAN", albumartist="ophiDIAN", format="CD", year=2003, album="BLACKBOX", title="KhAmElEoN", ), Item( artist="Ophidian", albumartist="Ophidian", format="CD", year=2003, album="Blackbox", title="Khameleon", ), ), ] class TestTitlecasePlugin(PluginTestCase): plugin = "titlecase" preload_plugin = False def test_auto(self): """Ensure automatic processing gets assigned""" with self.configure_plugin({"auto": True, "after_choice": True}): assert callable(TitlecasePlugin().import_stages[0]) with self.configure_plugin({"auto": False, "after_choice": False}): assert len(TitlecasePlugin().import_stages) == 0 with self.configure_plugin({"auto": False, "after_choice": True}): assert len(TitlecasePlugin().import_stages) == 0 def test_basic_titlecase(self): """Check that default behavior is as expected.""" testcases = [ ("a", "A"), ("PENDULUM", "Pendulum"), ("Aaron-carl", "Aaron-Carl"), ("LTJ bukem", "LTJ Bukem"), ("(original mix)", "(Original Mix)"), ("ALL CAPS TITLE", "All Caps Title"), ] for testcase in testcases: given, expected = testcase assert TitlecasePlugin().titlecase(given) == expected def test_small_first_last(self): """Check the behavior for supporting small first last""" testcases = [ (True, "In a Silent Way", "In a Silent Way"), (False, "In a Silent Way", "in a Silent Way"), ] for testcase in testcases: sfl, given, expected = testcase cfg = {"small_first_last": sfl} with self.configure_plugin(cfg): assert TitlecasePlugin().titlecase(given) == expected def test_preserve(self): """Test using given strings to preserve case""" preserve_list = [ "easyFun", "A.D.O.R", "D'Angelo", "ABBA", "LaTeX", "O.R.B", "PinkPantheress", "THE PSYCHIC ED RUSH", "LTJ Bukem", ] for word in preserve_list: with self.configure_plugin({"preserve": preserve_list}): assert TitlecasePlugin().titlecase(word.upper()) == word assert TitlecasePlugin().titlecase(word.lower()) == word def test_separators(self): testcases = [ ([], "it / a / in / of / to / the", "It / a / in / of / to / The"), (["/"], "it / the test", "It / The Test"), ( ["/"], "it / a / in / of / to / the", "It / A / In / Of / To / The", ), (["/"], "//it/a/in/of/to/the", "//It/A/In/Of/To/The"), ( ["/", ";", "|"], "it ; a / in | of / to | the", "It ; A / In | Of / To | The", ), ] for testcase in testcases: separators, given, expected = testcase with self.configure_plugin({"separators": separators}): assert TitlecasePlugin().titlecase(given) == expected def test_all_caps(self): testcases = [ (True, "Unaffected", "Unaffected"), (True, "RBMK1000", "RBMK1000"), (False, "RBMK1000", "Rbmk1000"), (True, "P A R I S!", "P A R I S!"), (True, "pillow dub...", "Pillow Dub..."), (False, "P A R I S!", "P a R I S!"), ] for testcase in testcases: all_caps, given, expected = testcase with self.configure_plugin({"all_caps": all_caps}): assert TitlecasePlugin().titlecase(given) == expected def test_all_lowercase(self): testcases = [ (True, "Unaffected", "Unaffected"), (True, "RBMK1000", "Rbmk1000"), (True, "pillow dub...", "pillow dub..."), (False, "pillow dub...", "Pillow Dub..."), ] for testcase in testcases: all_lowercase, given, expected = testcase with self.configure_plugin({"all_lowercase": all_lowercase}): assert TitlecasePlugin().titlecase(given) == expected def test_received_info_handler(self): testcases = [ ( TrackInfo( album="test album", artist_credit="test artist credit", artists=["artist one", "artist two"], ), TrackInfo( album="Test Album", artist_credit="Test Artist Credit", artists=["Artist One", "Artist Two"], ), ), ( AlbumInfo( tracks=[ TrackInfo( album="test album", artist_credit="test artist credit", artists=["artist one", "artist two"], ) ], album="test album", artist_credit="test artist credit", artists=["artist one", "artist two"], ), AlbumInfo( tracks=[ TrackInfo( album="Test Album", artist_credit="Test Artist Credit", artists=["Artist One", "Artist Two"], ) ], album="Test Album", artist_credit="Test Artist Credit", artists=["Artist One", "Artist Two"], ), ), ] cfg = {"fields": ["album", "artist_credit", "artists"]} for testcase in testcases: given, expected = testcase with self.configure_plugin(cfg): TitlecasePlugin().received_info_handler(given) assert given == expected def test_titlecase_fields(self): testcases = [ # Test with preserve, replace, and mb_albumid # Test with the_artist ( { "preserve": ["D'Angelo"], "replace": [("’", "'")], "fields": ["artist", "albumartist", "mb_albumid"], }, Item( artist="d’angelo and the vanguard", mb_albumid="ab140e13-7b36-402a-a528-b69e3dee38a8", albumartist="d’angelo", format="CD", album="the black messiah", title="Till It's Done (Tutu)", ), Item( artist="D'Angelo and The Vanguard", mb_albumid="Ab140e13-7b36-402a-A528-B69e3dee38a8", albumartist="D'Angelo", format="CD", album="the black messiah", title="Till It's Done (Tutu)", ), ), # Test with force_lowercase, preserve, and an incorrect field ( { "force_lowercase": True, "fields": [ "artist", "albumartist", "format", "title", "year", "label", "format", "INCORRECT_FIELD", ], "preserve": ["CD"], }, Item( artist="OPHIDIAN", albumartist="OphiDIAN", format="cd", year=2003, album="BLACKBOX", title="KhAmElEoN", label="enzyme records", ), Item( artist="Ophidian", albumartist="Ophidian", format="CD", year=2003, album="Blackbox", title="Khameleon", label="Enzyme Records", ), ), # Test with no changes ( { "fields": [ "artist", "artists", "albumartist", "format", "title", "year", "label", "format", "INCORRECT_FIELD", ], "preserve": ["CD"], }, Item( artist="Ophidian", artists=["Ophidian"], albumartist="Ophidian", format="CD", year=2003, album="Blackbox", title="Khameleon", label="Enzyme Records", ), Item( artist="Ophidian", artists=["Ophidian"], albumartist="Ophidian", format="CD", year=2003, album="Blackbox", title="Khameleon", label="Enzyme Records", ), ), # Test with the_artist disabled ( { "the_artist": False, "fields": [ "artist", "artists_sort", ], }, Item( artists_sort=["b-52s, the"], artist="a day in the park", ), Item( artists_sort=["B-52s, The"], artist="A Day in the Park", ), ), # Test to make sure preserve and the_artist # dont target the middle of sentences # show that The artist applies to any field # with artist mentioned ( { "preserve": ["PANTHER"], "fields": ["artist", "artists", "artists_ids"], }, Item( artist="pinkpantheress", artists=["pinkpantheress", "artist_two"], artists_ids=["the the", "the the"], ), Item( artist="Pinkpantheress", artists=["Pinkpantheress", "Artist_two"], artists_ids=["The The", "The The"], ), ), ] for testcase in testcases: cfg, given, expected = testcase with self.configure_plugin(cfg): TitlecasePlugin().titlecase_fields(given) assert given.artist == expected.artist assert given.artists == expected.artists assert given.artists_sort == expected.artists_sort assert given.albumartist == expected.albumartist assert given.artists_ids == expected.artists_ids assert given.format == expected.format assert given.year == expected.year assert given.title == expected.title assert given.label == expected.label def test_cli_write(self): given = Item( album="retrodelica 2: back 2 the future", artist="blue planet corporation", title="generator", ) expected = Item( album="Retrodelica 2: Back 2 the Future", artist="Blue Planet Corporation", title="Generator", ) cfg = {"fields": ["album", "artist", "title"]} with self.configure_plugin(cfg): given.add(self.lib) self.run_command("titlecase") assert self.lib.items().get().artist == expected.artist assert self.lib.items().get().album == expected.album assert self.lib.items().get().title == expected.title self.lib.items().get().remove() def test_cli_no_write(self): given = Item( album="retrodelica 2: back 2 the future", artist="blue planet corporation", title="generator", ) expected = Item( album="retrodelica 2: back 2 the future", artist="blue planet corporation", title="generator", ) cfg = {"fields": ["album", "artist", "title"]} with self.configure_plugin(cfg): given.add(self.lib) self.run_command("-p", "titlecase") assert self.lib.items().get().artist == expected.artist assert self.lib.items().get().album == expected.album assert self.lib.items().get().title == expected.title self.lib.items().get().remove() def test_imported(self): given = Item( album="retrodelica 2: back 2 the future", artist="blue planet corporation", title="generator", ) expected = Item( album="Retrodelica 2: Back 2 the Future", artist="Blue Planet Corporation", title="Generator", ) p = patch("beets.importer.ImportTask.imported_items", lambda x: [given]) p.start() with self.configure_plugin({"fields": ["album", "artist", "title"]}): import_session = ImportSession( self.lib, loghandler=None, paths=None, query=None ) import_task = ImportTask(toppath=None, paths=None, items=[given]) TitlecasePlugin().imported(import_session, import_task) import_task.add(self.lib) item = self.lib.items().get() assert item.artist == expected.artist assert item.album == expected.album assert item.title == expected.title p.stop() ================================================ FILE: test/plugins/test_types_plugin.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import time from datetime import datetime import pytest from confuse import ConfigValueError from beets.test.helper import IOMixin, PluginTestCase class TypesPluginTest(IOMixin, PluginTestCase): plugin = "types" def test_integer_modify_and_query(self): self.config["types"] = {"myint": "int"} item = self.add_item(artist="aaa") # Do not match unset values out = self.list("myint:1..3") assert "" == out self.modify("myint=2") item.load() assert item["myint"] == 2 # Match in range out = self.list("myint:1..3") assert "aaa" in out def test_album_integer_modify_and_query(self): self.config["types"] = {"myint": "int"} album = self.add_album(albumartist="aaa") # Do not match unset values out = self.list_album("myint:1..3") assert "" == out self.modify("-a", "myint=2") album.load() assert album["myint"] == 2 # Match in range out = self.list_album("myint:1..3") assert "aaa" in out def test_float_modify_and_query(self): self.config["types"] = {"myfloat": "float"} item = self.add_item(artist="aaa") # Do not match unset values out = self.list("myfloat:10..0") assert "" == out self.modify("myfloat=-9.1") item.load() assert item["myfloat"] == -9.1 # Match in range out = self.list("myfloat:-10..0") assert "aaa" in out def test_bool_modify_and_query(self): self.config["types"] = {"mybool": "bool"} true = self.add_item(artist="true") false = self.add_item(artist="false") self.add_item(artist="unset") # Do not match unset values out = self.list("mybool:true, mybool:false") assert "" == out # Set true self.modify("mybool=1", "artist:true") true.load() assert true["mybool"] # Set false self.modify("mybool=false", "artist:false") false.load() assert not false["mybool"] # Query bools out = self.list("mybool:true", "$artist $mybool") assert "true True" == out out = self.list("mybool:false", "$artist $mybool") # Dealing with unset fields? # assert 'false False' == out # out = self.list('mybool:', '$artist $mybool') # assert 'unset $mybool' in out def test_date_modify_and_query(self): self.config["types"] = {"mydate": "date"} # FIXME parsing should also work with default time format self.config["time_format"] = "%Y-%m-%d" old = self.add_item(artist="prince") new = self.add_item(artist="britney") # Do not match unset values out = self.list("mydate:..2000") assert "" == out self.modify("mydate=1999-01-01", "artist:prince") old.load() assert old["mydate"] == mktime(1999, 1, 1) self.modify("mydate=1999-12-30", "artist:britney") new.load() assert new["mydate"] == mktime(1999, 12, 30) # Match in range out = self.list("mydate:..1999-07", "$artist $mydate") assert "prince 1999-01-01" == out # FIXME some sort of timezone issue here # out = self.list('mydate:1999-12-30', '$artist $mydate') # assert 'britney 1999-12-30' == out def test_unknown_type_error(self): self.config["types"] = {"flex": "unkown type"} with pytest.raises(ConfigValueError): self.add_item(flex="test") def test_template_if_def(self): # Tests for a subtle bug when using %ifdef in templates along with # types that have truthy default values (e.g. '0', '0.0', 'False') # https://github.com/beetbox/beets/issues/3852 self.config["types"] = { "playcount": "int", "rating": "float", "starred": "bool", } with_fields = self.add_item(artist="prince") self.modify("playcount=10", "artist=prince") self.modify("rating=5.0", "artist=prince") self.modify("starred=yes", "artist=prince") with_fields.load() without_fields = self.add_item(artist="britney") int_template = "%ifdef{playcount,Play count: $playcount,Not played}" assert with_fields.evaluate_template(int_template) == "Play count: 10" assert without_fields.evaluate_template(int_template) == "Not played" float_template = "%ifdef{rating,Rating: $rating,Not rated}" assert with_fields.evaluate_template(float_template) == "Rating: 5.0" assert without_fields.evaluate_template(float_template) == "Not rated" bool_template = "%ifdef{starred,Starred: $starred,Not starred}" assert with_fields.evaluate_template(bool_template).lower() in ( "starred: true", "starred: yes", "starred: y", ) assert without_fields.evaluate_template(bool_template) == "Not starred" def modify(self, *args): return self.run_with_output( "modify", "--yes", "--nowrite", "--nomove", *args ) def list(self, query, fmt="$artist - $album - $title"): return self.run_with_output("ls", "-f", fmt, query).strip() def list_album(self, query, fmt="$albumartist - $album - $title"): return self.run_with_output("ls", "-a", "-f", fmt, query).strip() def mktime(*args): return time.mktime(datetime(*args).timetuple()) ================================================ FILE: test/plugins/test_web.py ================================================ """Tests for the 'web' plugin""" import json import os.path import platform import shutil from collections import Counter from beets import logging from beets.library import Album, Item from beets.test import _common from beets.test.helper import ItemInDBTestCase from beetsplug import web class WebPluginTest(ItemInDBTestCase): def setUp(self): super().setUp() self.log = logging.getLogger("beets.web") if platform.system() == "Windows": self.path_prefix = "C:" else: self.path_prefix = "" # Add fixtures for track in self.lib.items(): track.remove() # Add library elements. Note that self.lib.add overrides any "id=<n>" # and assigns the next free id number. # The following adds will create items #1, #2 and #3 path1 = ( self.path_prefix + os.sep + os.path.join(b"path_1").decode("utf-8") ) self.lib.add( Item(title="title", path=path1, album_id=2, artist="AAA Singers") ) path2 = ( self.path_prefix + os.sep + os.path.join(b"somewhere", b"a").decode("utf-8") ) self.lib.add( Item(title="another title", path=path2, artist="AAA Singers") ) path3 = ( self.path_prefix + os.sep + os.path.join(b"somewhere", b"abc").decode("utf-8") ) self.lib.add( Item(title="and a third", testattr="ABC", path=path3, album_id=2) ) # The following adds will create albums #1 and #2 self.lib.add(Album(album="album", albumtest="xyz")) path4 = ( self.path_prefix + os.sep + os.path.join(b"somewhere2", b"art_path_2").decode("utf-8") ) self.lib.add(Album(album="other album", artpath=path4)) web.app.config["TESTING"] = True web.app.config["lib"] = self.lib web.app.config["INCLUDE_PATHS"] = False web.app.config["READONLY"] = True self.client = web.app.test_client() def test_config_include_paths_true(self): web.app.config["INCLUDE_PATHS"] = True response = self.client.get("/item/1") res_json = json.loads(response.data.decode("utf-8")) expected_path = ( self.path_prefix + os.sep + os.path.join(b"path_1").decode("utf-8") ) assert response.status_code == 200 assert res_json["path"] == expected_path web.app.config["INCLUDE_PATHS"] = False def test_config_include_artpaths_true(self): web.app.config["INCLUDE_PATHS"] = True response = self.client.get("/album/2") res_json = json.loads(response.data.decode("utf-8")) expected_path = ( self.path_prefix + os.sep + os.path.join(b"somewhere2", b"art_path_2").decode("utf-8") ) assert response.status_code == 200 assert res_json["artpath"] == expected_path web.app.config["INCLUDE_PATHS"] = False def test_config_include_paths_false(self): web.app.config["INCLUDE_PATHS"] = False response = self.client.get("/item/1") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert "path" not in res_json def test_config_include_artpaths_false(self): web.app.config["INCLUDE_PATHS"] = False response = self.client.get("/album/2") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert "artpath" not in res_json def test_get_all_items(self): response = self.client.get("/item/") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["items"]) == 3 def test_get_unique_item_artist(self): response = self.client.get("/item/values/artist") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["values"] == ["", "AAA Singers"] def test_get_single_item_by_id(self): response = self.client.get("/item/1") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == 1 assert res_json["title"] == "title" def test_get_multiple_items_by_id(self): response = self.client.get("/item/1,2") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["items"]) == 2 response_titles = {item["title"] for item in res_json["items"]} assert response_titles == {"title", "another title"} def test_get_single_item_not_found(self): response = self.client.get("/item/4") assert response.status_code == 404 def test_get_single_item_by_path(self): data_path = os.path.join(_common.RSRC, b"full.mp3") self.lib.add(Item.from_path(data_path)) response = self.client.get(f"/item/path/{data_path.decode('utf-8')}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["title"] == "full" def test_get_single_item_by_path_not_found_if_not_in_library(self): data_path = os.path.join(_common.RSRC, b"full.mp3") # data_path points to a valid file, but we have not added the file # to the library. response = self.client.get(f"/item/path/{data_path.decode('utf-8')}") assert response.status_code == 404 def test_get_item_empty_query(self): response = self.client.get("/item/query/") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["items"]) == 3 def test_get_simple_item_query(self): response = self.client.get("/item/query/another") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["title"] == "another title" def test_query_item_string(self): response = self.client.get("/item/query/testattr%3aABC") # testattr:ABC res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["title"] == "and a third" def test_query_item_regex(self): response = self.client.get( "/item/query/testattr%3a%3a[A-C]%2b" ) # testattr::[A-C]+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["title"] == "and a third" def test_query_item_regex_backslash(self): response = self.client.get( "/item/query/testattr%3a%3a%5cw%2b" ) # testattr::\w+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["title"] == "and a third" def test_query_item_path(self): """Note: path queries are special: the query item must match the path from the root all the way to a directory, so this matches 1 item""" """ Note: filesystem separators in the query must be '\' """ response = self.client.get( "/item/query/path:" + self.path_prefix + "\\somewhere\\a" ) res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["title"] == "another title" def test_get_all_albums(self): response = self.client.get("/album/") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 response_albums = [album["album"] for album in res_json["albums"]] assert Counter(response_albums) == {"album": 1, "other album": 1} def test_get_single_album_by_id(self): response = self.client.get("/album/2") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == 2 assert res_json["album"] == "other album" def test_get_multiple_albums_by_id(self): response = self.client.get("/album/1,2") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 response_albums = [album["album"] for album in res_json["albums"]] assert Counter(response_albums) == {"album": 1, "other album": 1} def test_get_album_empty_query(self): response = self.client.get("/album/query/") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["albums"]) == 2 def test_get_simple_album_query(self): response = self.client.get("/album/query/other") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["album"] == "other album" assert res_json["results"][0]["id"] == 2 def test_get_album_details(self): response = self.client.get("/album/2?expand") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["items"]) == 2 assert res_json["items"][0]["album"] == "other album" assert res_json["items"][1]["album"] == "other album" response_track_titles = {item["title"] for item in res_json["items"]} assert response_track_titles == {"title", "and a third"} def test_query_album_string(self): response = self.client.get( "/album/query/albumtest%3axy" ) # albumtest:xy res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["album"] == "album" def test_query_album_artpath_regex(self): response = self.client.get( "/album/query/artpath%3a%3aart_" ) # artpath::art_ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["album"] == "other album" def test_query_album_regex_backslash(self): response = self.client.get( "/album/query/albumtest%3a%3a%5cw%2b" ) # albumtest::\w+ res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 assert res_json["results"][0]["album"] == "album" def test_get_stats(self): response = self.client.get("/stats") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["items"] == 3 assert res_json["albums"] == 2 def test_delete_item_id(self): web.app.config["READONLY"] = False # Create a temporary item item_id = self.lib.add( Item(title="test_delete_item_id", test_delete_item_id=1) ) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id response = self.client.delete(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Note: if this fails, the item may still be around # and may cause other tests to fail def test_delete_item_without_file(self): web.app.config["READONLY"] = False # Create an item with a file ipath = os.path.join(self.temp_dir, b"testfile1.mp3") shutil.copy(os.path.join(_common.RSRC, b"full.mp3"), ipath) assert os.path.exists(ipath) item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id, without deleting file response = self.client.delete(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Check the file has not gone assert os.path.exists(ipath) os.remove(ipath) def test_delete_item_with_file(self): web.app.config["READONLY"] = False # Create an item with a file ipath = os.path.join(self.temp_dir, b"testfile2.mp3") shutil.copy(os.path.join(_common.RSRC, b"full.mp3"), ipath) assert os.path.exists(ipath) item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Delete item by id, with file response = self.client.delete(f"/item/{item_id}?delete") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone response = self.client.get(f"/item/{item_id}") assert response.status_code == 404 # Check the file has gone assert not os.path.exists(ipath) def test_delete_item_query(self): web.app.config["READONLY"] = False # Create a temporary item self.lib.add( Item(title="test_delete_item_query", test_delete_item_query=1) ) # Check we can find the temporary item we just created response = self.client.get("/item/query/test_delete_item_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Delete item by query response = self.client.delete("/item/query/test_delete_item_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the item has gone response = self.client.get("/item/query/test_delete_item_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 0 def test_delete_item_all_fails(self): """DELETE is not supported for list all""" web.app.config["READONLY"] = False # Delete all items response = self.client.delete("/item/") assert response.status_code == 405 # Note: if this fails, all items have gone and rest of # tests will fail! def test_delete_item_id_readonly(self): web.app.config["READONLY"] = True # Create a temporary item item_id = self.lib.add( Item(title="test_delete_item_id_ro", test_delete_item_id_ro=1) ) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Try to delete item by id response = self.client.delete(f"/item/{item_id}") assert response.status_code == 405 # Check the item has not gone response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id # Remove it self.lib.get_item(item_id).remove() def test_delete_item_query_readonly(self): web.app.config["READONLY"] = True # Create a temporary item item_id = self.lib.add( Item(title="test_delete_item_q_ro", test_delete_item_q_ro=1) ) # Check we can find the temporary item we just created response = self.client.get("/item/query/test_delete_item_q_ro") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Try to delete item by query response = self.client.delete("/item/query/test_delete_item_q_ro") assert response.status_code == 405 # Check the item has not gone response = self.client.get("/item/query/test_delete_item_q_ro") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Remove it self.lib.get_item(item_id).remove() def test_delete_album_id(self): web.app.config["READONLY"] = False # Create a temporary album album_id = self.lib.add( Album(album="test_delete_album_id", test_delete_album_id=1) ) # Check we can find the temporary album we just created response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id # Delete album by id response = self.client.delete(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the album has gone response = self.client.get(f"/album/{album_id}") assert response.status_code == 404 # Note: if this fails, the album may still be around # and may cause other tests to fail def test_delete_album_query(self): web.app.config["READONLY"] = False # Create a temporary album self.lib.add( Album(album="test_delete_album_query", test_delete_album_query=1) ) # Check we can find the temporary album we just created response = self.client.get("/album/query/test_delete_album_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Delete album response = self.client.delete("/album/query/test_delete_album_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 # Check the album has gone response = self.client.get("/album/query/test_delete_album_query") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 0 def test_delete_album_all_fails(self): """DELETE is not supported for list all""" web.app.config["READONLY"] = False # Delete all albums response = self.client.delete("/album/") assert response.status_code == 405 # Note: if this fails, all albums have gone and rest of # tests will fail! def test_delete_album_id_readonly(self): web.app.config["READONLY"] = True # Create a temporary album album_id = self.lib.add( Album(album="test_delete_album_id_ro", test_delete_album_id_ro=1) ) # Check we can find the temporary album we just created response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id # Try to delete album by id response = self.client.delete(f"/album/{album_id}") assert response.status_code == 405 # Check the item has not gone response = self.client.get(f"/album/{album_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == album_id # Remove it self.lib.get_album(album_id).remove() def test_delete_album_query_readonly(self): web.app.config["READONLY"] = True # Create a temporary album album_id = self.lib.add( Album( album="test_delete_album_query_ro", test_delete_album_query_ro=1 ) ) # Check we can find the temporary album we just created response = self.client.get("/album/query/test_delete_album_query_ro") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Try to delete album response = self.client.delete("/album/query/test_delete_album_query_ro") assert response.status_code == 405 # Check the album has not gone response = self.client.get("/album/query/test_delete_album_query_ro") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert len(res_json["results"]) == 1 # Remove it self.lib.get_album(album_id).remove() def test_patch_item_id(self): # Note: PATCH is currently only implemented for track items, not albums web.app.config["READONLY"] = False # Create a temporary item item_id = self.lib.add( Item( title="test_patch_item_id", test_patch_f1=1, test_patch_f2="Old" ) ) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id assert res_json["test_patch_f1"] == "1" assert res_json["test_patch_f2"] == "Old" # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}]}) response = self.client.patch( f"/item/{item_id}", json={"test_patch_f2": "New"} ) res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id assert res_json["test_patch_f1"] == "1" assert res_json["test_patch_f2"] == "New" # Check the update has really worked response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id assert res_json["test_patch_f1"] == "1" assert res_json["test_patch_f2"] == "New" # Remove the item self.lib.get_item(item_id).remove() def test_patch_item_id_readonly(self): # Note: PATCH is currently only implemented for track items, not albums web.app.config["READONLY"] = True # Create a temporary item item_id = self.lib.add( Item( title="test_patch_item_id_ro", test_patch_f1=2, test_patch_f2="Old", ) ) # Check we can find the temporary item we just created response = self.client.get(f"/item/{item_id}") res_json = json.loads(response.data.decode("utf-8")) assert response.status_code == 200 assert res_json["id"] == item_id assert res_json["test_patch_f1"] == "2" assert res_json["test_patch_f2"] == "Old" # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}) response = self.client.patch( f"/item/{item_id}", json={"test_patch_f2": "New"} ) assert response.status_code == 405 # Remove the item self.lib.get_item(item_id).remove() def test_get_item_file(self): ipath = os.path.join(self.temp_dir, b"testfile2.mp3") shutil.copy(os.path.join(_common.RSRC, b"full.mp3"), ipath) assert os.path.exists(ipath) item_id = self.lib.add(Item.from_path(ipath)) response = self.client.get(f"/item/{item_id}/file") assert response.status_code == 200 ================================================ FILE: test/plugins/test_zero.py ================================================ """Tests for the 'zero' plugin""" from mediafile import MediaFile from beets.library import Item from beets.test.helper import IOMixin, PluginTestCase from beets.util import syspath from beetsplug.zero import ZeroPlugin class ZeroPluginTest(IOMixin, PluginTestCase): plugin = "zero" preload_plugin = False def test_no_patterns(self): item = self.add_item_fixture( comments="test comment", title="Title", month=1, year=2000, ) item.write() with self.configure_plugin({"fields": ["comments", "month"]}): item.write() mf = MediaFile(syspath(item.path)) assert mf.comments is None assert mf.month is None assert mf.title == "Title" assert mf.year == 2000 def test_pattern_match(self): item = self.add_item_fixture(comments="encoded by encoder") item.write() with self.configure_plugin( {"fields": ["comments"], "comments": ["encoded by"]} ): item.write() mf = MediaFile(syspath(item.path)) assert mf.comments is None def test_pattern_nomatch(self): item = self.add_item_fixture(comments="recorded at place") item.write() with self.configure_plugin( {"fields": ["comments"], "comments": ["encoded_by"]} ): item.write() mf = MediaFile(syspath(item.path)) assert mf.comments == "recorded at place" def test_do_not_change_database(self): item = self.add_item_fixture(year=2000) item.write() with self.configure_plugin({"fields": ["year"]}): item.write() assert item["year"] == 2000 def test_change_database(self): item = self.add_item_fixture(year=2000) item.write() with self.configure_plugin( {"fields": ["year"], "update_database": True} ): item.write() assert item["year"] == 0 def test_album_art(self): path = self.create_mediafile_fixture(images=["jpg"]) item = Item.from_path(path) with self.configure_plugin({"fields": ["images"]}): item.write() mf = MediaFile(syspath(path)) assert not mf.images def test_auto_false(self): item = self.add_item_fixture(year=2000) item.write() with self.configure_plugin( {"fields": ["year"], "update_database": True, "auto": False} ): item.write() assert item["year"] == 2000 def test_subcommand_update_database_true(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" ) item.write() item_id = item.id with self.configure_plugin( {"fields": ["comments"], "update_database": True, "auto": False} ): self.io.addinput("y") self.run_command("zero") mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) assert item["year"] == 2016 assert mf.year == 2016 assert mf.comments is None assert item["comments"] == "" def test_subcommand_update_database_false(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" ) item.write() item_id = item.id with self.configure_plugin( { "fields": ["comments"], "update_database": False, "auto": False, } ): self.io.addinput("y") self.run_command("zero") mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) assert item["year"] == 2016 assert mf.year == 2016 assert item["comments"] == "test comment" assert mf.comments is None def test_subcommand_query_include(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" ) item.write() with self.configure_plugin( {"fields": ["comments"], "update_database": False, "auto": False} ): self.run_command("zero", "year: 2016") mf = MediaFile(syspath(item.path)) assert mf.year == 2016 assert mf.comments is None def test_subcommand_query_exclude(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" ) item.write() with self.configure_plugin( {"fields": ["comments"], "update_database": False, "auto": False} ): self.run_command("zero", "year: 0000") mf = MediaFile(syspath(item.path)) assert mf.year == 2016 assert mf.comments == "test comment" def test_no_fields(self): item = self.add_item_fixture(year=2016) item.write() mediafile = MediaFile(syspath(item.path)) assert mediafile.year == 2016 item_id = item.id with self.configure_plugin({"fields": []}): self.io.addinput("y") self.run_command("zero") item = self.lib.get_item(item_id) assert item["year"] == 2016 assert mediafile.year == 2016 def test_whitelist_and_blacklist(self): item = self.add_item_fixture(year=2016) item.write() mf = MediaFile(syspath(item.path)) assert mf.year == 2016 item_id = item.id with self.configure_plugin( {"fields": ["year"], "keep_fields": ["comments"]} ): self.io.addinput("y") self.run_command("zero") item = self.lib.get_item(item_id) assert item["year"] == 2016 assert mf.year == 2016 def test_keep_fields(self): item = self.add_item_fixture(year=2016, comments="test comment") tags = { "comments": "test comment", "year": 2016, } with self.configure_plugin( {"fields": None, "keep_fields": ["year"], "update_database": True} ): z = ZeroPlugin() z.write_event(item, item.path, tags) assert tags["comments"] is None assert tags["year"] == 2016 def test_keep_fields_removes_preserved_tags(self): self.config["zero"]["keep_fields"] = ["year"] self.config["zero"]["fields"] = None self.config["zero"]["update_database"] = True z = ZeroPlugin() assert "id" not in z.fields_to_progs def test_fields_removes_preserved_tags(self): self.config["zero"]["fields"] = ["year id"] self.config["zero"]["update_database"] = True z = ZeroPlugin() assert "id" not in z.fields_to_progs def test_omit_single_disc_with_tags_single(self): item = self.add_item_fixture( disctotal=1, disc=1, comments="test comment" ) item.write() with self.configure_plugin( {"omit_single_disc": True, "fields": ["comments"]} ): item.write() mf = MediaFile(syspath(item.path)) assert mf.comments is None assert mf.disc is None assert mf.disctotal is None def test_omit_single_disc_with_tags_multi(self): item = self.add_item_fixture( disctotal=4, disc=1, comments="test comment" ) item.write() with self.configure_plugin( {"omit_single_disc": True, "fields": ["comments"]} ): item.write() mf = MediaFile(syspath(item.path)) assert mf.comments is None assert mf.disc == 1 assert mf.disctotal == 4 def test_omit_single_disc_only_change_single(self): item = self.add_item_fixture(disctotal=1, disc=1) item.write() with self.configure_plugin({"omit_single_disc": True}): item.write() mf = MediaFile(syspath(item.path)) assert mf.disc is None assert mf.disctotal is None def test_omit_single_disc_only_change_multi(self): item = self.add_item_fixture(disctotal=4, disc=1) item.write() with self.configure_plugin({"omit_single_disc": True}): item.write() mf = MediaFile(syspath(item.path)) assert mf.disc == 1 assert mf.disctotal == 4 def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments="test comment" ) item.write() item_id = item.id with self.configure_plugin( {"fields": ["comments"], "update_database": True, "auto": False} ): self.io.addinput("n") self.run_command("zero") mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) assert item["year"] == 2016 assert mf.year == 2016 assert mf.comments == "test comment" assert item["comments"] == "test comment" ================================================ FILE: test/plugins/utils/__init__.py ================================================ ================================================ FILE: test/plugins/utils/test_musicbrainz.py ================================================ import pytest from beetsplug._utils.musicbrainz import MusicBrainzAPI def test_group_relations(): raw_release = { "id": "r1", "relations": [ {"target-type": "artist", "type": "vocal", "name": "A"}, {"target-type": "url", "type": "streaming", "url": "http://s"}, {"target-type": "url", "type": "purchase", "url": "http://p"}, { "target-type": "work", "type": "performance", "work": { "relations": [ { "artist": {"name": "幾田りら"}, "target-type": "artist", "type": "composer", }, { "target-type": "url", "type": "lyrics", "url": { "resource": "https://utaten.com/lyric/tt24121002/" }, }, { "artist": {"name": "幾田りら"}, "target-type": "artist", "type": "lyricist", }, { "target-type": "url", "type": "lyrics", "url": { "resource": "https://www.uta-net.com/song/366579/" }, }, ], "title": "百花繚乱", "type": "Song", }, }, ], } assert MusicBrainzAPI._group_relations(raw_release) == { "id": "r1", "artist-relations": [{"type": "vocal", "name": "A"}], "url-relations": [ {"type": "streaming", "url": "http://s"}, {"type": "purchase", "url": "http://p"}, ], "work-relations": [ { "type": "performance", "work": { "artist-relations": [ {"type": "composer", "artist": {"name": "幾田りら"}}, {"type": "lyricist", "artist": {"name": "幾田りら"}}, ], "url-relations": [ { "type": "lyrics", "url": { "resource": "https://utaten.com/lyric/tt24121002/" }, }, { "type": "lyrics", "url": { "resource": "https://www.uta-net.com/song/366579/" }, }, ], "title": "百花繚乱", "type": "Song", }, }, ], } @pytest.mark.parametrize( "field, term, expected", [ ("artist", ' AC/DC + "[Live]" ', r"artist:(ac\/dc \+ \"\[live\]\")"), ("", "Foo:Bar", r"foo\:bar"), ("artist", " ", ""), ], ) def test_format_search_term(field, term, expected): assert MusicBrainzAPI.format_search_term(field, term) == expected ================================================ FILE: test/plugins/utils/test_vfs.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the virtual filesystem builder..""" from beets.test import _common from beets.test.helper import BeetsTestCase from beetsplug._utils import vfs class VFSTest(BeetsTestCase): def setUp(self): super().setUp() self.lib.path_formats = [ ("default", "albums/$album/$title"), ("singleton:true", "tracks/$artist/$title"), ] self.lib.add(_common.item()) self.lib.add_album([_common.item()]) self.tree = vfs.libtree(self.lib) def test_singleton_item(self): assert ( self.tree.dirs["tracks"].dirs["the artist"].files["the title"] == 1 ) def test_album_item(self): assert ( self.tree.dirs["albums"].dirs["the album"].files["the title"] == 2 ) ================================================ FILE: test/rsrc/acousticbrainz/data.json ================================================ { "tonal":{ "thpcp":[ 1, 0.638657510281, 0.293813556433, 0.259863913059, 0.21968896687, 0.218203336, 0.252398610115, 0.22969686985, 0.447383195162, 0.749422073364, 0.580664932728, 0.310822367668, 0.238883554935, 0.178785249591, 0.194924846292, 0.299323320389, 0.282649427652, 0.18946044147, 0.181915551424, 0.231100782752, 0.554247200489, 0.831909179688, 0.589426040649, 0.387799620628, 0.422363936901, 0.429372549057, 0.408978521824, 0.326897829771, 0.266663640738, 0.429461866617, 0.633336126804, 0.477401226759, 0.261826515198, 0.238164439797, 0.287726253271, 0.690547764301 ], "chords_number_rate":0.00194468453992, "chords_scale":"minor", "chords_changes_rate":0.0445116683841, "key_strength":0.636936545372, "tuning_diatonic_strength":0.495492935181, "hpcp_entropy":{ "min":0, "max":4.48086500168, "dvar2":0.867867648602, "median":2.02990412712, "dmean2":1.14721953869, "dmean":0.68769723177, "var":0.635742008686, "dvar":0.33780092001, "mean":2.00384068489 }, "key_scale":"minor", "chords_strength":{ "min":0.24240244925, "max":0.793840110302, "dvar2":9.58399032243e-05, "median":0.586153388023, "dmean2":0.0106231365353, "dmean":0.00929547380656, "var":0.00910324696451, "dvar":6.61800950184e-05, "mean":0.576524615288 }, "key_key":"A", "tuning_nontempered_energy_ratio":0.721719145775, "tuning_equal_tempered_deviation":0.0515233427286, "chords_histogram":[ 56.2445983887, 8.10285186768, 1.79343128204, 0.0864304229617, 0, 0.605012953281, 2.20397591591, 12.1650819778, 0.0216076057404, 0.0216076057404, 0, 0, 0, 0, 0, 0, 2.67934322357, 0.21607606113, 10.8686256409, 0, 2.07433009148, 0.0864304229617, 0.648228168488, 2.1823682785 ], "chords_key":"A", "tuning_frequency":441.272583008, "hpcp":{ "min":[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], "max":[ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 ], "dvar2":[ 0.159377709031, 0.118723139167, 0.0969077348709, 0.0841393470764, 0.0857475027442, 0.0681946650147, 0.0922033339739, 0.0763805955648, 0.08953332901, 0.134413808584, 0.117157392204, 0.0784328207374, 0.0576078519225, 0.0540019907057, 0.0537950210273, 0.0788798704743, 0.0758254900575, 0.0659088715911, 0.0595937520266, 0.0897909551859, 0.117696471512, 0.141075149179, 0.116812512279, 0.143778041005, 0.157332316041, 0.206293225288, 0.187901929021, 0.16186593473, 0.119209326804, 0.107413217425, 0.119033068419, 0.101279519498, 0.102868333459, 0.108767814934, 0.105039291084, 0.133028581738 ], "median":[ 0.200053632259, 0.138681918383, 0.0352100357413, 0.0198299698532, 0.0150980614126, 0.0205171480775, 0.026256872341, 0.0286095552146, 0.0424337461591, 0.0602139532566, 0.0744276791811, 0.0494470596313, 0.0350396670401, 0.0212194472551, 0.0196186862886, 0.0201223343611, 0.0170610919595, 0.0120322443545, 0.0105668697506, 0.0191436801106, 0.0846247002482, 0.135070502758, 0.113241240382, 0.0424886606634, 0.0378469713032, 0.02946896106, 0.0286043733358, 0.0198593121022, 0.0253958441317, 0.0672319456935, 0.0957452505827, 0.0639808028936, 0.026175301522, 0.0180807597935, 0.0324410535395, 0.125212281942 ], "dmean2":[ 0.353859990835, 0.294070899487, 0.217189013958, 0.180236533284, 0.162956178188, 0.144071772695, 0.172789543867, 0.171253859997, 0.22391949594, 0.284728109837, 0.274570196867, 0.198656648397, 0.154297873378, 0.124744221568, 0.124510832131, 0.157537952065, 0.158150732517, 0.135058999062, 0.123808719218, 0.177607372403, 0.277576059103, 0.324962347746, 0.298212528229, 0.308668285608, 0.311420857906, 0.344731658697, 0.335885316133, 0.283822774887, 0.227637752891, 0.252195805311, 0.288448363543, 0.249307155609, 0.212308287621, 0.205825775862, 0.223324626684, 0.315198987722 ], "dmean":[ 0.206140458584, 0.16949801147, 0.121237404644, 0.101308584213, 0.0920756608248, 0.0820758640766, 0.0983714461327, 0.0957674309611, 0.130757495761, 0.167563140392, 0.159212738276, 0.113431841135, 0.0875298455358, 0.0698468312621, 0.0709747001529, 0.092557400465, 0.0927894487977, 0.0761438235641, 0.0697580873966, 0.0992580577731, 0.162719354033, 0.194310605526, 0.174188151956, 0.171439617872, 0.174783751369, 0.191449582577, 0.185274213552, 0.155063658953, 0.124152831733, 0.143824338913, 0.167870178819, 0.144021183252, 0.116702638566, 0.112653404474, 0.125104308128, 0.184472203255 ], "var":[ 0.143021538854, 0.0637424811721, 0.0320195667446, 0.037381041795, 0.0286979582161, 0.0301742851734, 0.0303927082568, 0.0223984327167, 0.052778493613, 0.13409627974, 0.0731517747045, 0.0302681028843, 0.0220995694399, 0.0194978509098, 0.0208596177399, 0.0510722063482, 0.0462048053741, 0.0236530210823, 0.0288583170623, 0.0295663233846, 0.0644663274288, 0.119045428932, 0.0609758161008, 0.0493184439838, 0.0607626289129, 0.070556551218, 0.0664848089218, 0.0493576526642, 0.0335861295462, 0.0447917319834, 0.0859667509794, 0.0526875928044, 0.0304508917034, 0.0315008088946, 0.0328483134508, 0.0794815197587 ], "dvar":[ 0.0686646401882, 0.0436623394489, 0.0358338914812, 0.0332863405347, 0.0316647030413, 0.027725789696, 0.0338032282889, 0.0273791830987, 0.0345646068454, 0.0615020208061, 0.0457257218659, 0.0297911148518, 0.0221415478736, 0.0201385207474, 0.0201426595449, 0.033235758543, 0.0318886972964, 0.0243560094386, 0.0229729004204, 0.0327679589391, 0.0460740588605, 0.0626438856125, 0.0435314439237, 0.0521778166294, 0.0578555390239, 0.0772548541427, 0.0728902295232, 0.05926451087, 0.0424739196897, 0.0389089211822, 0.0483759790659, 0.0381603203714, 0.036982499063, 0.0398408733308, 0.0387278683484, 0.0517609193921 ], "mean":[ 0.366864055395, 0.234300479293, 0.107789635658, 0.0953347310424, 0.080595985055, 0.0800509601831, 0.0925959795713, 0.084267526865, 0.164128810167, 0.274936020374, 0.213025093079, 0.114029549062, 0.0876377895474, 0.0655898824334, 0.0715109184384, 0.109810970724, 0.103693917394, 0.0695062279701, 0.0667382776737, 0.0847825706005, 0.203333377838, 0.305197566748, 0.216239228845, 0.142269745469, 0.154950141907, 0.157521352172, 0.15003952384, 0.119927063584, 0.0978293046355, 0.157554119825, 0.232348263264, 0.175141349435, 0.0960547402501, 0.0873739719391, 0.105556420982, 0.253337144852 ] } }, "rhythm":{ "bpm_histogram_second_peak_bpm":{ "min":167, "max":167, "dvar2":0, "median":167, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":167 }, "bpm_histogram_second_peak_spread":{ "min":0, "max":0, "dvar2":0, "median":0, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":0 }, "beats_count":577, "beats_loudness":{ "min":4.48232695405e-09, "max":0.181520029902, "dvar2":0.00434844521806, "median":0.0302296206355, "dmean2":0.0709233134985, "dmean":0.0362482257187, "var":0.0014705004869, "dvar":0.00124853104353, "mean":0.0407853461802 }, "bpm":162.532119751, "bpm_histogram_first_peak_spread":{ "min":0.164835140109, "max":0.164835140109, "dvar2":0, "median":0.164835140109, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":0.164835140109 }, "danceability":1.14192211628, "bpm_histogram_first_peak_bpm":{ "min":161, "max":161, "dvar2":0, "median":161, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":161 }, "beats_loudness_band_ratio":{ "min":[ 0.00683269277215, 0.00988945644349, 0.00177479430567, 0.000523661612533, 0.000248342636041, 0.00070228939876 ], "max":[ 0.970164954662, 0.725397408009, 0.739950060844, 0.658194899559, 0.676319360733, 0.622089266777 ], "dvar2":[ 0.0699004009366, 0.0290632098913, 0.035205449909, 0.0226975940168, 0.0168868545443, 0.0086750369519 ], "median":[ 0.619332075119, 0.176913484931, 0.0926762372255, 0.0303400773555, 0.0398489944637, 0.0262520890683 ], "dmean2":[ 0.369491040707, 0.230186283588, 0.192849993706, 0.106605380774, 0.107576675713, 0.0616580061615 ], "dmean":[ 0.207789510489, 0.129003107548, 0.110624760389, 0.0578341074288, 0.0602345280349, 0.035002540797 ], "var":[ 0.0565228238702, 0.0165011454374, 0.0193302389234, 0.0086074648425, 0.00634109321982, 0.00296737346798 ], "dvar":[ 0.0250691790134, 0.00962078291923, 0.0126609709114, 0.00782771967351, 0.00567667419091, 0.00305109703913 ], "mean":[ 0.577607989311, 0.195795580745, 0.138790622354, 0.0610357522964, 0.065284781158, 0.04240244627 ] }, "onset_rate":5.17941665649, "beats_position":[ 0.383129239082, 0.789478421211, 1.16099774837, 1.53251695633, 1.90403628349, 2.27555561066, 2.6470746994, 3.01859402657, 3.39011335373, 3.76163268089, 4.13315200806, 4.5046710968, 4.87619018555, 5.24770975113, 5.60761880875, 5.9675283432, 6.33904743195, 6.71056699753, 7.08208608627, 7.45360517502, 7.81351470947, 8.17342376709, 8.54494285583, 8.91646194458, 9.287981987, 9.64789104462, 10.0194101334, 10.379319191, 10.7508392334, 11.110748291, 11.4822673798, 11.8421764374, 12.2136955261, 12.5852155685, 12.9567346573, 13.3166437149, 13.6765527725, 14.0364627838, 14.3963718414, 14.7678909302, 15.1394100189, 15.5109291077, 15.870839119, 16.2307472229, 16.602268219, 16.9621772766, 17.3220863342, 17.693605423, 18.0535144806, 18.4134235382, 18.7733325958, 19.1332416534, 19.4931507111, 19.853061676, 20.2245807648, 20.5844898224, 20.9560089111, 21.3159179688, 21.6874370575, 22.0473461151, 22.4072551727, 22.7787742615, 23.1386852264, 23.4985942841, 23.8701133728, 24.2416324615, 24.6131515503, 24.984670639, 25.3561897278, 25.7277088165, 26.0992279053, 26.4591369629, 26.830657959, 27.2021770477, 27.5736961365, 27.9452152252, 28.316734314, 28.6766433716, 29.0481624603, 29.4196815491, 29.7912006378, 30.1627197266, 30.5342407227, 30.9057598114, 31.265668869, 31.6255779266, 31.9854869843, 32.3453979492, 32.6704750061, 33.0071640015, 33.3554649353, 33.7153739929, 34.0868911743, 34.4351921082, 34.8067131042, 35.1898422241, 35.5961914062, 35.9793205261, 36.362449646, 36.7339668274, 37.1054878235, 37.4886169434, 37.8601341248, 38.2316551208, 38.6147842407, 38.9863014221, 39.3578224182, 39.7293434143, 40.1124725342, 40.4723815918, 40.8438987732, 41.2154197693, 41.5869369507, 41.9584579468, 42.3299751282, 42.7014961243, 43.0730133057, 43.4445343018, 43.8160552979, 44.1875724792, 44.5590934753, 44.9306106567, 45.3021316528, 45.6736488342, 46.0335578918, 46.3934669495, 46.7533760071, 47.1132888794, 47.4848060608, 47.8563270569, 48.2394561768, 48.6225852966, 48.994102478, 49.3656234741, 49.748752594, 50.1318817139, 50.5033988953, 50.8749198914, 51.2580490112, 51.6411781311, 52.0126991272, 52.3842163086, 52.7557373047, 53.1272544861, 53.4987754822, 53.8702926636, 54.2534217834, 54.6365509033, 55.0080718994, 55.3795890808, 55.7511100769, 56.122631073, 56.4941482544, 56.8656692505, 57.2487983704, 57.6203155518, 58.0034446716, 58.3749656677, 58.7464828491, 59.1180038452, 59.4895210266, 59.8610420227, 60.2325630188, 60.6040802002, 60.9756011963, 61.3471183777, 61.7186393738, 62.0901565552, 62.4616775513, 62.8331947327, 63.2163238525, 63.5994529724, 63.9709739685, 64.3424911499, 64.714012146, 65.0855331421, 65.4570541382, 65.8285675049, 66.200088501, 66.5716094971, 66.9431304932, 67.3146438599, 67.686164856, 68.0344696045, 68.4175949097, 68.8007278442, 69.1722412109, 69.5321502686, 69.8920593262, 70.2635803223, 70.6351013184, 70.995010376, 71.3549194336, 71.7264404297, 72.1095657349, 72.481086731, 72.8642196655, 73.2357330322, 73.6072540283, 73.9787750244, 74.3502960205, 74.7218093872, 75.1049423218, 75.488067627, 75.859588623, 76.2311096191, 76.6026306152, 76.9741516113, 77.345664978, 77.7171859741, 78.1003189087, 78.4834442139, 78.85496521, 79.2264862061, 79.5979995728, 79.9695205688, 80.3410415649, 80.712562561, 81.0840835571, 81.4555969238, 81.8271179199, 82.198638916, 82.5701599121, 82.9416732788, 83.3131942749, 83.684715271, 84.0678405762, 84.4509735107, 84.8224945068, 85.1824035645, 85.5423126221, 85.9138336182, 86.2853469849, 86.656867981, 87.0283889771, 87.3999099731, 87.7714233398, 88.1429443359, 88.514465332, 88.8859863281, 89.2575073242, 89.6290206909, 89.9889297485, 90.3488388062, 90.7203598022, 91.0918807983, 91.451789856, 91.8116989136, 92.1832199097, 92.5547409058, 92.9262542725, 93.2861633301, 93.6460723877, 94.0175933838, 94.3891143799, 94.760635376, 95.1205444336, 95.4804534912, 95.8403625488, 96.2002716064, 96.5717926025, 96.9433059692, 97.3148269653, 97.6863479614, 98.0578689575, 98.4293823242, 98.8009033203, 99.1724243164, 99.532333374, 99.9038543701, 100.263763428, 100.635284424, 101.006797791, 101.378318787, 101.738227844, 102.098136902, 102.469657898, 102.841178894, 103.21269989, 103.584213257, 103.944122314, 104.304031372, 104.675552368, 105.047073364, 105.406982422, 105.766891479, 106.138412476, 106.509933472, 106.881446838, 107.252967834, 107.624488831, 107.984397888, 108.355918884, 108.715827942, 109.087341309, 109.458862305, 109.818771362, 110.17868042, 110.538589478, 110.898498535, 111.270019531, 111.641540527, 112.001449585, 112.361358643, 112.732879639, 113.104400635, 113.464309692, 113.82421875, 114.184127808, 114.544036865, 114.915550232, 115.287071228, 115.658592224, 116.006889343, 116.366798401, 116.726707458, 117.086616516, 117.446525574, 117.794830322, 118.15473938, 118.514648438, 118.886169434, 119.269294739, 119.652427673, 120.035552979, 120.418685913, 120.813423157, 121.2081604, 121.602897644, 121.997642517, 122.380767822, 122.763900757, 123.147026062, 123.530158997, 123.92489624, 124.319633484, 124.69115448, 125.085891724, 125.480628967, 125.863761902, 126.246894836, 126.630020142, 127.013153076, 127.396278381, 127.779411316, 128.17414856, 128.568893433, 128.963623047, 129.346755981, 129.729888916, 130.113006592, 130.496139526, 130.867660522, 131.239181519, 131.610702515, 131.982223511, 132.353744507, 132.725265503, 133.09677124, 133.456680298, 133.816589355, 134.188110352, 134.559631348, 134.931152344, 135.30267334, 135.674194336, 136.045715332, 136.417236328, 136.788757324, 137.160263062, 137.520172119, 137.880081177, 138.251602173, 138.623123169, 138.994644165, 139.366165161, 139.737686157, 140.109207153, 140.480728149, 140.852249146, 141.223754883, 141.595275879, 141.943572998, 142.315093994, 142.675003052, 143.034912109, 143.406433105, 143.777954102, 144.149475098, 144.520996094, 144.89251709, 145.264038086, 145.635559082, 146.007064819, 146.378585815, 146.750106812, 147.121627808, 147.493148804, 147.8646698, 148.236190796, 148.607711792, 148.979232788, 149.362350464, 149.745483398, 150.117004395, 150.488525391, 150.860046387, 151.231567383, 151.603088379, 151.974594116, 152.346115112, 152.717636108, 153.089157104, 153.460678101, 153.832199097, 154.203720093, 154.575241089, 154.946762085, 155.318267822, 155.689788818, 156.061309814, 156.432830811, 156.804351807, 157.175872803, 157.547393799, 157.918914795, 158.290420532, 158.661941528, 159.033462524, 159.404983521, 159.776504517, 160.148025513, 160.519546509, 160.891067505, 161.262588501, 161.634094238, 162.005615234, 162.37713623, 162.737045288, 163.096954346, 163.468475342, 163.839996338, 164.211517334, 164.58303833, 164.954559326, 165.326080322, 165.674377441, 166.045898438, 166.417419434, 166.788925171, 167.160446167, 167.531967163, 167.903488159, 168.275009155, 168.646530151, 169.018051147, 169.389572144, 169.772689819, 170.155822754, 170.52734375, 170.898864746, 171.270385742, 171.641906738, 172.025024414, 172.408157349, 172.779678345, 173.151199341, 173.522720337, 173.894241333, 174.265762329, 174.637283325, 175.032012939, 175.415145874, 175.798278809, 176.169799805, 176.541305542, 176.9012146, 177.272735596, 177.632644653, 178.004165649, 178.375686646, 178.747207642, 179.130340576, 179.513473511, 179.884979248, 180.256500244, 180.62802124, 180.999542236, 181.382675171, 181.754196167, 182.137313843, 182.508834839, 182.880355835, 183.251876831, 183.623397827, 183.994918823, 184.378051758, 184.761169434, 185.13269043, 185.504211426, 185.875732422, 186.247253418, 186.618774414, 186.99029541, 187.361816406, 187.733337402, 188.10484314, 188.476364136, 188.847885132, 189.219406128, 189.590927124, 189.96244812, 190.345581055, 190.72869873, 191.100219727, 191.471740723, 191.843261719, 192.214782715, 192.597915649, 192.981033325, 193.352554321, 193.724075317, 194.095596313, 194.478729248, 194.861862183, 195.23336792, 195.604888916, 195.976409912, 196.347930908, 196.719451904, 197.0909729, 197.474105835, 197.857223511, 198.228744507, 198.600265503, 198.971786499, 199.343307495, 199.714828491, 200.086349487, 200.457870483, 200.829391479, 201.200897217, 201.572418213, 201.943939209, 202.303848267, 202.663757324, 203.03527832, 203.406799316, 203.778320312, 204.149841309, 204.521362305, 204.892883301, 205.264389038, 205.647521973, 206.030654907, 206.402175903, 206.773696899, 207.145217896, 207.516723633, 207.888244629, 208.259765625, 208.631286621, 209.002807617, 209.385940552, 209.757461548, 210.128982544, 210.500488281, 210.883621216, 211.255142212, 211.626663208, 212.009796143, 212.392913818, 212.776046753, 213.170791626, 213.565536499, 213.971878052, 214.378234863 ], "bpm_histogram_second_peak_weight":{ "min":0.163194447756, "max":0.163194447756, "dvar2":0, "median":0.163194447756, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":0.163194447756 }, "bpm_histogram_first_peak_weight":{ "min":0.659722208977, "max":0.659722208977, "dvar2":0, "median":0.659722208977, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":0.659722208977 } }, "lowlevel":{ "spectral_complexity":{ "min":0, "max":51, "dvar2":34.109500885, "median":15, "dmean2":5.03252983093, "dmean":3.31208133698, "var":108.135475159, "dvar":16.1623840332, "mean":15.1260938644 }, "silence_rate_20dB":{ "min":1, "max":1, "dvar2":0, "median":1, "dmean2":0, "dmean":0, "var":0, "dvar":0, "mean":1 }, "average_loudness":0.815025985241, "erbbands_spread":{ "min":0.907018482685, "max":163.113571167, "dvar2":511.106719971, "median":34.9861183167, "dmean2":17.4016342163, "dmean":11.4227361679, "var":572.351501465, "dvar":220.769592285, "mean":39.7502365112 }, "spectral_kurtosis":{ "min":-1.22816824913, "max":77.2247085571, "dvar2":28.6044521332, "median":4.62249898911, "dmean2":4.51546525955, "dmean":2.82785224915, "var":33.1767959595, "dvar":13.1654214859, "mean":6.00768327713 }, "barkbands_kurtosis":{ "min":-1.96124887466, "max":732.31427002, "dvar2":588.577392578, "median":2.02856016159, "dmean2":8.05669307709, "dmean":5.11299228668, "var":374.321380615, "dvar":247.842544556, "mean":7.06718301773 }, "spectral_strongpeak":{ "min":1.71490974199e-10, "max":16.1200447083, "dvar2":3.3518705368, "median":0.39955753088, "dmean2":0.941311240196, "dmean":0.545010268688, "var":2.96148967743, "dvar":1.33243703842, "mean":0.93408870697 }, "spectral_spread":{ "min":1388334.875, "max":41575076, "dvar2":1619247235070.0, "median":3420763.25, "dmean2":983456.875, "dmean":732694.4375, "var":4945877663740.0, "dvar":854983901184, "mean":4104934.25 }, "spectral_rms":{ "min":3.15950651059e-12, "max":0.0132316453382, "dvar2":3.71212809114e-06, "median":0.00425734650344, "dmean2":0.0013317943085, "dmean":0.000880118692294, "var":5.96411746301e-06, "dvar":1.66359927789e-06, "mean":0.0039903158322 }, "erbbands":{ "min":[ 1.77816177391e-22, 1.16505097519e-21, 1.25622764484e-20, 2.42817334007e-20, 1.54258117469e-20, 1.0398112018e-19, 1.45132858053e-19, 2.45289269564e-19, 4.01068342766e-19, 8.21050273636e-19, 3.81530310848e-19, 8.81511903137e-19, 1.07499647945e-18, 8.4783438187e-19, 1.00474413342e-18, 2.63416224414e-18, 2.7020495774e-18, 3.62243276547e-18, 3.19863277569e-18, 3.43176763428e-18, 6.46998299903e-18, 5.15079866686e-18, 8.52004633608e-18, 1.02257547977e-17, 1.00997123247e-17, 1.05472856921e-17, 1.00685656659e-17, 1.10003920706e-17, 1.51110777052e-17, 1.5046216819e-17, 1.31580720189e-17, 1.30844504628e-17, 1.13508668418e-17, 1.02681982621e-17, 8.04728449187e-18, 4.73097927698e-18, 2.75997186582e-18, 1.08864568334e-18, 2.8601904839e-19, 3.5584382605e-20 ], "max":[ 2.05614852905, 10.580906868, 60.5060195923, 152.843505859, 450.042755127, 175.338409424, 237.807418823, 238.884460449, 443.897521973, 525.930541992, 518.611022949, 1031.67468262, 954.232910156, 1123.45935059, 556.857299805, 356.125640869, 342.635467529, 1293.60327148, 1103.80981445, 1287.36242676, 2169.76928711, 1266.18579102, 601.754516602, 1258.96850586, 444.097320557, 240.62600708, 303.504180908, 56.5521354675, 72.2374343872, 85.4121780396, 25.407957077, 19.8146400452, 46.2547264099, 16.3226490021, 16.8903636932, 5.15064907074, 0.893250703812, 0.284754753113, 0.106889992952, 0.00452945847064 ], "dvar2":[ 0.0137821119279, 4.26913785934, 97.7259292603, 622.611999512, 3656.42504883, 376.053527832, 307.56942749, 320.293243408, 2445.90405273, 1300.15063477, 729.621582031, 2693.6159668, 2013.25512695, 3353.13598633, 573.952941895, 269.22253418, 457.528259277, 1406.78710938, 1557.82128906, 793.165527344, 5019.24316406, 4039.60839844, 970.312316895, 1538.21313477, 721.875244141, 195.41003418, 88.4793777466, 31.4306335449, 42.7237434387, 32.8801956177, 6.04226541519, 1.52690029144, 1.47338938713, 0.542356073856, 0.211627364159, 0.0269440039992, 0.00465576630086, 0.000269315525657, 1.98406451091e-05, 4.38383267465e-08 ], "median":[ 0.00466703856364, 0.101253904402, 1.64267027378, 5.13034963608, 6.49109458923, 4.258228302, 3.18711066246, 4.03815889359, 7.81102657318, 7.54452610016, 5.75877952576, 8.21393203735, 11.6316785812, 9.69087696075, 4.23464298248, 1.69191777706, 2.67072510719, 4.43968248367, 4.24516201019, 7.75287914276, 16.0199928284, 12.5332527161, 9.28721427917, 9.18504428864, 6.87661838531, 4.15781497955, 3.11850094795, 0.707400023937, 0.422007799149, 0.326348185539, 0.171605303884, 0.0764799118042, 0.0530340671539, 0.0369812250137, 0.0170799326152, 0.00388540537097, 0.000607917027082, 0.000106923354906, 2.54503829638e-05, 2.41674279096e-05 ], "dmean2":[ 0.0468679144979, 1.00080478191, 4.60071754456, 11.530172348, 27.0302009583, 10.1726131439, 8.18716049194, 9.02602672577, 21.6493034363, 17.5054721832, 11.9851293564, 23.2072429657, 23.1678962708, 24.8249855042, 9.62737751007, 5.9968290329, 8.08820819855, 12.5483970642, 11.0777654648, 12.6770420074, 31.1724281311, 22.2209968567, 15.6441850662, 17.6994724274, 11.4936075211, 6.59135293961, 5.08022689819, 2.30506944656, 2.95904040337, 2.46585559845, 1.15297198296, 0.531184136868, 0.382049500942, 0.275081396103, 0.141834661365, 0.0498343594372, 0.0225136969239, 0.00503297196701, 0.000977287418209, 5.82068387303e-05 ], "dmean":[ 0.0350424982607, 0.580041825771, 2.61035442352, 6.80757236481, 16.318693161, 6.28967905045, 5.23286628723, 5.70099258423, 13.4230766296, 10.8768568039, 7.74926900864, 15.0034389496, 15.0271100998, 16.6018199921, 6.02632713318, 3.79855132103, 5.38237333298, 8.43670463562, 7.43314123154, 8.2191324234, 20.6921386719, 13.7538080215, 9.77100944519, 11.3139772415, 7.25735664368, 4.1218457222, 3.17433810234, 1.45581579208, 1.87883663177, 1.61556243896, 0.779599308968, 0.371349811554, 0.264476120472, 0.193502560258, 0.100708886981, 0.0353026203811, 0.0144239943475, 0.00325388251804, 0.000670222449116, 3.95594870497e-05 ], "var":[ 0.0116662262008, 1.27503490448, 25.6204109192, 206.069885254, 1430.30371094, 234.442382812, 206.473526001, 288.805267334, 1444.05480957, 817.272949219, 625.690795898, 2697.92749023, 2397.11669922, 7130.13330078, 515.86315918, 346.633789062, 809.448486328, 2584.76660156, 2616.26757812, 3138.03320312, 20583.1132812, 3381.78515625, 1288.27880859, 2477.11401367, 682.099060059, 216.604751587, 97.8326339722, 13.7823019028, 19.9818115234, 16.6690158844, 4.43811273575, 1.2346996069, 1.27807950974, 0.517578363419, 0.177246898413, 0.0190207827836, 0.00211605615914, 0.000168058744748, 1.65621640917e-05, 3.32350325039e-08 ], "dvar":[ 0.00734147289768, 1.59334981441, 34.9658050537, 236.386993408, 1519.1640625, 157.498901367, 126.366111755, 127.708183289, 921.020629883, 497.140319824, 303.813903809, 1122.60107422, 868.916992188, 1545.74194336, 241.721923828, 118.562904358, 218.458129883, 734.227844238, 762.639587402, 405.900024414, 2714.4777832, 1520.69287109, 428.032836914, 696.146972656, 272.255096436, 80.3479690552, 38.2512245178, 12.4188299179, 17.8483753204, 13.7868928909, 2.77658462524, 0.731714189053, 0.67433899641, 0.263227701187, 0.108624838293, 0.0132785160094, 0.00197272375226, 0.000125091624795, 1.03948887045e-05, 2.18264126772e-08 ], "mean":[ 0.0470404699445, 0.51214581728, 2.94917726517, 8.56711673737, 16.8939933777, 9.74743175507, 8.58794498444, 10.7102499008, 23.4056625366, 18.6381034851, 13.9866991043, 27.1086997986, 28.1797733307, 34.376159668, 10.7114057541, 6.6013917923, 10.7804918289, 16.5257949829, 15.1251926422, 19.3569641113, 49.2880210876, 27.8654499054, 20.180316925, 22.9017887115, 15.1088733673, 8.45034122467, 5.79618597031, 1.91187810898, 2.04077577591, 1.82269322872, 0.964613378048, 0.475511223078, 0.359998822212, 0.270887732506, 0.138232842088, 0.0431671403348, 0.0138024548069, 0.00314460648224, 0.000714557303581, 5.64505244256e-05 ] }, "zerocrossingrate":{ "min":0.00244140625, "max":0.525390625, "dvar2":0.000216632659431, "median":0.04736328125, "dmean2":0.0121012274176, "dmean":0.0100563038141, "var":0.00131242838688, "dvar":0.000186675912119, "mean":0.0536084286869 }, "spectral_contrast_coeffs":{ "min":[ -0.981265366077, -0.957678437233, -0.97364538908, -0.967074155807, -0.96257597208, -0.963937044144 ], "max":[ -0.232828617096, -0.450794398785, -0.464783608913, -0.367899239063, -0.556100070477, -0.624147236347 ], "dvar2":[ 0.0063711241819, 0.00358002260327, 0.00270585925318, 0.0017158848932, 0.00113679806236, 0.00189194327686 ], "median":[ -0.592801511288, -0.736532092094, -0.745844006538, -0.781389117241, -0.797656714916, -0.772757589817 ], "dmean2":[ 0.0916584655643, 0.074229337275, 0.0648957416415, 0.0500396862626, 0.0380141288042, 0.0374387130141 ], "dmean":[ 0.0592447705567, 0.0465519614518, 0.0414465926588, 0.0322540737689, 0.024834824726, 0.0243127625436 ], "var":[ 0.00911337323487, 0.00655136583373, 0.00618056347594, 0.00577284349129, 0.00380114861764, 0.00231571029872 ], "dvar":[ 0.00270068016835, 0.00148758781143, 0.00118501938414, 0.000784370582551, 0.000524090020917, 0.000761664705351 ], "mean":[ -0.594863533974, -0.735448241234, -0.745020091534, -0.775078713894, -0.790849328041, -0.779701292515 ] }, "dissonance":{ "min":0.155567497015, "max":0.500000119209, "dvar2":0.0011708199745, "median":0.469446510077, "dmean2":0.0329371914268, "dmean":0.0200165640563, "var":0.00120261416305, "dvar":0.000480501999846, "mean":0.460001558065 }, "spectral_energyband_high":{ "min":7.11060368084e-21, "max":0.00607443926856, "dvar2":2.99654345781e-07, "median":0.000152749445988, "dmean2":0.000292699754937, "dmean":0.000196773456992, "var":2.3810963512e-07, "dvar":1.35860290129e-07, "mean":0.000318794423947 }, "gfcc":{ "mean":[ -75.1009368896, 108.134063721, -114.922393799, 35.9068107605, -53.9456176758, -7.74362421036, -37.9716300964, 9.9239988327, -24.244802475, -10.4905223846, -23.3989257812, -9.61326217651, -9.66184806824 ], "icov":[ [ 8.00217167125e-05, -0.00014524954895, 0.000159682429512, -0.000167002261151, 9.01917956071e-05, -0.000181695446372, 0.000171542298631, -0.000129780688439, 0.000197500339709, -0.000278050283669, 0.000315256824251, -0.000206819575396, 0.000114160277008 ], [ -0.00014524954895, 0.00178359099664, -0.000247094896622, 0.00111800106242, -0.000636783661321, 0.000774645654019, -0.000396145915147, 0.000103466474684, -0.00112135277595, 0.00229536322877, -0.00234188255854, 0.00232740258798, -0.00131408020388 ], [ 0.000159682429512, -0.000247094896622, 0.00116660131607, -0.000234406528762, 0.000154832057888, -0.00112980930135, 0.000650760543067, -5.60496459912e-07, 0.000167754784343, -0.000856044876855, 0.0014209097717, -0.00128671491984, 0.00078388658585 ], [ -0.000167002261151, 0.00111800106242, -0.000234406528762, 0.00312983314507, -0.000794405816123, -0.00058439996792, 2.24104460358e-05, 0.000424961413955, -0.00152624503244, 0.00239069177769, -0.00202889670618, 0.00110484787729, -0.00148455286399 ], [ 9.01917956071e-05, -0.000636783661321, 0.000154832057888, -0.000794405816123, 0.00397709663957, -0.000828172138426, -0.000291677570203, -0.000975954462774, 0.00101370830089, -0.00107421039138, -0.000894718454219, 0.00066822959343, -0.000943991879467 ], [ -0.000181695446372, 0.000774645654019, -0.00112980930135, -0.00058439996792, -0.000828172138426, 0.00597975216806, -0.00177403702401, -0.000384801533073, 0.00153620564379, -0.00195281521883, 0.00158954621293, 0.000321330269799, 0.000257747073192 ], [ 0.000171542298631, -0.000396145915147, 0.000650760543067, 2.24104460358e-05, -0.000291677570203, -0.00177403702401, 0.0080866701901, -0.00180288043339, 7.330061635e-05, 0.00111615261994, -0.0011511715129, -0.000984402606264, 0.000181279159733 ], [ -0.000129780688439, 0.000103466474684, -5.60496459912e-07, 0.000424961413955, -0.000975954462774, -0.000384801533073, -0.00180288043339, 0.00851836241782, -0.00320849893615, 0.00141180318315, -0.00124999764375, 0.000833041733131, -0.00159861764405 ], [ 0.000197500339709, -0.00112135277595, 0.000167754784343, -0.00152624503244, 0.00101370830089, 0.00153620564379, 7.330061635e-05, -0.00320849893615, 0.0134673845023, -0.00816399604082, 0.00590222747996, -0.00234723254107, -0.000930359237827 ], [ -0.000278050283669, 0.00229536322877, -0.000856044876855, 0.00239069177769, -0.00107421039138, -0.00195281521883, 0.00111615261994, 0.00141180318315, -0.00816399604082, 0.0195522867143, -0.0122134555131, 0.00327054248191, 0.000577625120059 ], [ 0.000315256824251, -0.00234188255854, 0.0014209097717, -0.00202889670618, -0.000894718454219, 0.00158954621293, -0.0011511715129, -0.00124999764375, 0.00590222747996, -0.0122134555131, 0.021111080423, -0.00721056666225, 0.00365835893899 ], [ -0.000206819575396, 0.00232740258798, -0.00128671491984, 0.00110484787729, 0.00066822959343, 0.000321330269799, -0.000984402606264, 0.000833041733131, -0.00234723254107, 0.00327054248191, -0.00721056666225, 0.015932681039, -0.00565396249294 ], [ 0.000114160277008, -0.00131408020388, 0.00078388658585, -0.00148455286399, -0.000943991879467, 0.000257747073192, 0.000181279159733, -0.00159861764405, -0.000930359237827, 0.000577625120059, 0.00365835893899, -0.00565396249294, 0.0153731228784 ] ], "cov":[ [ 21821.4921875, 1302.12060547, -2790.62133789, 583.293151855, 63.9840736389, 26.9333572388, -119.419998169, 277.984161377, -110.454193115, -76.0569458008, -16.7917041779, -162.89515686, 121.933448792 ], [ 1302.12060547, 1445.02355957, -661.351135254, -311.094665527, 156.139587402, -306.730987549, 60.9609375, 92.3673324585, 3.47471880913, -108.692207336, 66.6088409424, -147.776809692, 75.2410125732 ], [ -2790.62133789, -661.351135254, 1889.47399902, 92.8338088989, -92.8102874756, 368.803192139, -78.5678482056, -95.1785125732, 24.1375102997, 86.254699707, -94.2749252319, 117.902046204, -80.0259933472 ], [ 583.293151855, -311.094665527, 92.8338088989, 515.187255859, 60.5850448608, 128.642654419, -2.62003684044, 6.46807575226, 13.7108755112, -3.07335114479, -7.74705600739, 28.8581352234, 29.7626171112 ], [ 63.9840736389, 156.139587402, -92.8102874756, 60.5850448608, 339.564758301, 28.9691524506, 45.5025405884, 54.3213920593, -9.63641166687, 12.0036411285, 43.2324829102, -22.4280166626, 29.361246109 ], [ 26.9333572388, -306.730987549, 368.803192139, 128.642654419, 28.9691524506, 324.149993896, 20.1203899384, -3.79410982132, -15.0688257217, 43.0340042114, -26.3511829376, 28.6324996948, -22.8170604706 ], [ -119.419998169, 60.9609375, -78.5678482056, -2.62003684044, 45.5025405884, 20.1203899384, 155.001724243, 42.1054992676, -1.47882866859, -8.76181983948, 19.2513332367, 2.83816099167, 11.5606393814 ], [ 277.984161377, 92.3673324585, -95.1785125732, 6.46807575226, 54.3213920593, -3.79410982132, 42.1054992676, 155.586120605, 33.8702697754, -2.8296790123, 9.17068958282, -6.04741716385, 28.1404056549 ], [ -110.454193115, 3.47471880913, 24.1375102997, 13.7108755112, -9.63641166687, -15.0688257217, -1.47882866859, 33.8702697754, 114.661575317, 34.3392486572, -7.06085252762, 10.0253458023, 15.4272651672 ], [ -76.0569458008, -108.692207336, 86.254699707, -3.07335114479, 12.0036411285, 43.0340042114, -8.76181983948, -2.8296790123, 34.3392486572, 112.38079071, 43.6220817566, 14.6831378937, -20.7214431763 ], [ -16.7917041779, 66.6088409424, -94.2749252319, -7.74705600739, 43.2324829102, -26.3511829376, 19.2513332367, 9.17068958282, -7.06085252762, 43.6220817566, 99.527053833, 15.1529960632, -6.4773888588 ], [ -162.89515686, -147.776809692, 117.902046204, 28.8581352234, -22.4280166626, 28.6324996948, 2.83816099167, -6.04741716385, 10.0253458023, 14.6831378937, 15.1529960632, 101.876480103, 16.7505264282 ], [ 121.933448792, 75.2410125732, -80.0259933472, 29.7626171112, 29.361246109, -22.8170604706, 11.5606393814, 28.1404056549, 15.4272651672, -20.7214431763, -6.4773888588, 16.7505264282, 91.9189758301 ] ] }, "spectral_flux":{ "min":6.40516528705e-11, "max":0.355690479279, "dvar2":0.00278266565874, "median":0.0625253766775, "dmean2":0.0423415489495, "dmean":0.0282820314169, "var":0.00376007612795, "dvar":0.00135926820803, "mean":0.0749769806862 }, "silence_rate_30dB":{ "min":0, "max":1, "dvar2":0.0354892387986, "median":1, "dmean2":0.0246406570077, "dmean":0.0123189976439, "var":0.00654759025201, "dvar":0.0121672395617, "mean":0.993408977985 }, "spectral_energyband_middle_high":{ "min":1.27805372214e-21, "max":0.0389622859657, "dvar2":9.42645056057e-06, "median":0.00293085677549, "dmean2":0.00200005969964, "dmean":0.00133119279053, "var":2.5883437047e-05, "dvar":4.14980877395e-06, "mean":0.00445337453857 }, "barkbands_spread":{ "min":0.167884364724, "max":139.821090698, "dvar2":253.175750732, "median":18.0125980377, "dmean2":10.9470348358, "dmean":6.75650119781, "var":180.234161377, "dvar":101.147232056, "mean":20.320306778 }, "spectral_centroid":{ "min":111.030769348, "max":11065.2148438, "dvar2":701335.5625, "median":1143.22497559, "dmean2":567.022827148, "dmean":354.07824707, "var":626410.0625, "dvar":292997.4375, "mean":1266.39624023 }, "pitch_salience":{ "min":0.103756688535, "max":0.928133249283, "dvar2":0.013650230132, "median":0.569122076035, "dmean2":0.124187774956, "dmean":0.0767409279943, "var":0.0128469280899, "dvar":0.00529233785346, "mean":0.566493272781 }, "erbbands_skewness":{ "min":-8.09688186646, "max":6.17588758469, "dvar2":1.00871086121, "median":0.283587485552, "dmean2":0.786845207214, "dmean":0.512230575085, "var":1.42545306683, "dvar":0.427418738604, "mean":0.235957682133 }, "erbbands_crest":{ "min":2.31988024712, "max":34.2482185364, "dvar2":16.2832355499, "median":8.70411396027, "dmean2":4.28936433792, "dmean":2.70263242722, "var":24.3617858887, "dvar":6.85065603256, "mean":9.92293930054 }, "melbands":{ "min":[ 1.7312522864e-24, 4.69779527492e-24, 4.46513652814e-24, 3.32025441336e-24, 6.6967940523e-24, 5.54350662457e-24, 5.62380004294e-24, 7.24197266845e-24, 4.37425935743e-24, 6.31036856572e-24, 4.53679113075e-24, 3.08454218323e-24, 4.01757944901e-24, 5.05271286808e-24, 5.25895581858e-24, 3.18568913964e-24, 7.13208118891e-24, 4.5636159514e-24, 5.04583400098e-24, 6.17228950832e-24, 6.96855978959e-24, 4.44096109684e-24, 6.9472211021e-24, 7.30462636813e-24, 7.82425851273e-24, 7.32260214157e-24, 5.87433201125e-24, 6.79884662102e-24, 6.23345462746e-24, 6.36714722381e-24, 5.56132344254e-24, 7.90887173342e-24, 7.08392990812e-24, 8.85545433259e-24, 6.01856418372e-24, 6.31963413149e-24, 6.85279050744e-24, 7.75657976909e-24, 8.50479856047e-24, 7.94873918585e-24 ], "max":[ 0.0150078302249, 0.0248268786818, 0.0372853949666, 0.0202775932848, 0.00924268271774, 0.00459495186806, 0.00642714649439, 0.00673051225021, 0.00483322422951, 0.0058145863004, 0.00487699825317, 0.00474451202899, 0.00216139364056, 0.00089383253362, 0.000890302297194, 0.00114067236427, 0.00265396363102, 0.00179244857281, 0.00171541213058, 0.00327429641038, 0.00261263479479, 0.00140622933395, 0.000604341796134, 0.000546872266568, 0.00119282689411, 0.000385722087231, 0.000307112553855, 0.000185638607945, 0.000196822540602, 7.07383733243e-05, 3.59257646778e-05, 4.41324045823e-05, 5.90648887737e-05, 5.03649462189e-05, 1.98958878173e-05, 1.04367582026e-05, 1.49458364831e-05, 3.54613039235e-05, 2.11343995034e-05, 1.33671064759e-05 ], "dvar2":[ 1.97027497961e-06, 1.79839098564e-05, 3.41721388395e-05, 6.39068775854e-06, 4.12703258235e-07, 1.60991760367e-07, 5.84672534387e-07, 1.35801229817e-07, 5.67447884237e-08, 9.04100758703e-08, 5.12682660769e-08, 6.00754361813e-08, 8.33282598478e-09, 2.19900075926e-09, 2.22529794591e-09, 2.68628164157e-09, 6.38194253e-09, 4.48827552901e-09, 1.61791646747e-09, 5.6524527281e-09, 7.00124225261e-09, 5.1307971205e-09, 1.8114484357e-09, 6.22804863237e-10, 1.41800715614e-09, 5.03870334345e-10, 3.42071621029e-10, 8.80266623482e-11, 5.10801435871e-11, 1.6358601973e-11, 1.09966497713e-11, 1.34551406475e-11, 2.12651927317e-11, 8.70376774126e-12, 2.70498077062e-12, 8.23377390574e-13, 6.18267915163e-13, 8.94622212682e-13, 5.74140724113e-13, 4.04918747187e-13 ], "median":[ 6.78846045048e-05, 0.000686653598677, 0.00100371893495, 0.000425793463364, 0.000121567500173, 8.67325506988e-05, 0.000119830452604, 7.64572032494e-05, 4.73126528959e-05, 4.95156273246e-05, 5.10508471052e-05, 3.72932154278e-05, 1.39631702041e-05, 4.18296076532e-06, 3.36458288075e-06, 5.95153596805e-06, 7.06666241967e-06, 5.36886500413e-06, 6.55584017295e-06, 1.27752928165e-05, 1.80421066034e-05, 1.10708388092e-05, 9.13756139198e-06, 6.40786538497e-06, 6.87108331476e-06, 5.39385519005e-06, 3.31015212396e-06, 2.2452804842e-06, 2.11795463656e-06, 8.45357135404e-07, 1.55302529947e-07, 2.16630468231e-07, 2.0936421663e-07, 1.59076819273e-07, 1.03014848207e-07, 5.60769883862e-08, 4.16746956944e-08, 3.91173387015e-08, 3.08294119122e-08, 2.99958919925e-08 ], "dmean2":[ 0.000655197829474, 0.00202016695403, 0.00265697692521, 0.00127012608573, 0.000302697066218, 0.000204388590646, 0.000336306344252, 0.000177521447768, 0.000107648374978, 0.000137986353366, 0.000116639275802, 0.000105825478386, 3.63973231288e-05, 1.78256286745e-05, 1.63039130712e-05, 1.98472516786e-05, 2.55192408076e-05, 1.86820070667e-05, 1.45677859109e-05, 2.93159864668e-05, 3.81224999728e-05, 2.55198028754e-05, 1.86311717698e-05, 1.29478112285e-05, 1.63034928846e-05, 1.03988468254e-05, 6.90801107339e-06, 4.38494134869e-06, 3.91557023249e-06, 1.95062216335e-06, 1.26417035062e-06, 1.61554555689e-06, 1.957419272e-06, 1.21927041619e-06, 7.50478704958e-07, 4.08925643569e-07, 3.15493565495e-07, 2.87596407134e-07, 2.50877462804e-07, 2.38469112901e-07 ], "dmean":[ 0.000433199049439, 0.00115317711607, 0.00158822687808, 0.000778516754508, 0.000194163410924, 0.000129944470245, 0.00020776965539, 0.000110655011667, 7.02645556885e-05, 8.88201902853e-05, 7.56665394874e-05, 7.11579850758e-05, 2.26343472605e-05, 1.1214566257e-05, 1.06519555629e-05, 1.3082231817e-05, 1.72533091245e-05, 1.2507844076e-05, 9.44854855334e-06, 1.93181331269e-05, 2.54146216321e-05, 1.58707625815e-05, 1.14972417578e-05, 8.10282745078e-06, 1.0418824786e-05, 6.57239706925e-06, 4.29072770203e-06, 2.71895851256e-06, 2.42956934926e-06, 1.20713775686e-06, 7.98163171112e-07, 1.02098283605e-06, 1.22477297282e-06, 8.0222929455e-07, 4.99395412135e-07, 2.76074587191e-07, 2.18049038381e-07, 1.94722304059e-07, 1.73989079144e-07, 1.64999249819e-07 ], "var":[ 1.04273829038e-06, 4.88182786285e-06, 1.22067667689e-05, 3.09852111968e-06, 2.95254579896e-07, 1.60118688086e-07, 3.36397903311e-07, 9.02441499306e-08, 5.12204714198e-08, 8.87765096991e-08, 6.13447141973e-08, 1.30928043518e-07, 7.16093628839e-09, 2.90166846106e-09, 3.30743610277e-09, 3.75769282357e-09, 1.11862910046e-08, 7.38158290048e-09, 3.41670158832e-09, 2.24571348184e-08, 3.01072198283e-08, 4.89972462603e-09, 1.57648072374e-09, 7.79673825502e-10, 2.14637396745e-09, 5.14293607701e-10, 2.73522648975e-10, 8.45902653479e-11, 5.53298171169e-11, 1.09233455614e-11, 4.45680905375e-12, 6.09628753728e-12, 8.53084356628e-12, 4.52617804347e-12, 1.82034248786e-12, 6.15677430912e-13, 4.4449592119e-13, 6.71954570979e-13, 5.48820374997e-13, 3.48484424911e-13 ], "dvar":[ 8.33708043046e-07, 6.49838420941e-06, 1.36113221743e-05, 2.76258151644e-06, 1.75387782519e-07, 6.53309797372e-08, 2.18858644985e-07, 5.17116269805e-08, 2.40723956324e-08, 3.69722101823e-08, 2.22425278196e-08, 2.84932291095e-08, 3.64641872252e-09, 9.5759922214e-10, 9.86244308443e-10, 1.3016818734e-09, 3.31316440949e-09, 2.19745110996e-09, 7.73902275597e-10, 3.32642535739e-09, 3.88597554135e-09, 1.99106442444e-09, 7.00074609394e-10, 2.79730322239e-10, 6.32938867984e-10, 2.10190226335e-10, 1.22236526456e-10, 3.66416376407e-11, 2.22450686344e-11, 6.52528248449e-12, 4.34595848892e-12, 5.58480735616e-12, 8.46765019213e-12, 3.64534357561e-12, 1.20417478072e-12, 3.81005014864e-13, 2.8428255301e-13, 3.87186458025e-13, 2.78560404994e-13, 1.87431545436e-13 ], "mean":[ 0.00047609579633, 0.00126094208099, 0.00185862951912, 0.000971358967945, 0.000324478023686, 0.000246531388257, 0.000361267186236, 0.000190427192138, 0.000126440427266, 0.000159403600264, 0.000136641858262, 0.000143978060805, 3.71072310372e-05, 1.87067053048e-05, 1.93349878828e-05, 2.34964645642e-05, 3.1896517612e-05, 2.36660616793e-05, 1.83704396477e-05, 4.34660541941e-05, 5.81043132115e-05, 2.89009076369e-05, 2.15983145608e-05, 1.50616651808e-05, 1.95628890651e-05, 1.24749267343e-05, 8.04845058155e-06, 4.90500542583e-06, 4.20188916905e-06, 1.82525320724e-06, 8.70177814249e-07, 1.09259167402e-06, 1.25471365209e-06, 9.23780646644e-07, 6.02008753958e-07, 3.45971272964e-07, 2.69976595746e-07, 2.55056221476e-07, 2.36791535713e-07, 2.26877631349e-07 ] }, "spectral_entropy":{ "min":4.61501169205, "max":9.81467628479, "dvar2":0.184129029512, "median":7.38190746307, "dmean2":0.352754920721, "dmean":0.243219792843, "var":0.300432950258, "dvar":0.0902535244823, "mean":7.3010840416 }, "spectral_rolloff":{ "min":64.599609375, "max":21037.9394531, "dvar2":3396222, "median":861.328125, "dmean2":1057.10974121, "dmean":604.693481445, "var":2179523.25, "dvar":1425528, "mean":1383.6763916 }, "barkbands":{ "min":[ 6.93473619499e-25, 5.19568601854e-24, 1.02416202049e-23, 4.28463410897e-24, 3.04471689542e-23, 2.72335547301e-23, 3.08580014027e-23, 1.15517745957e-23, 5.01556722243e-23, 3.01743335215e-23, 3.20834273992e-23, 6.39242832255e-23, 4.96037188707e-23, 6.11980114915e-23, 7.73605723709e-23, 1.27850093686e-22, 8.33913319498e-23, 1.55698133196e-22, 1.80609161514e-22, 2.27535985033e-22, 3.03975723224e-22, 3.99622470031e-22, 4.5185483121e-22, 7.15649816942e-22, 1.01532623561e-21, 1.53862207745e-21, 2.31706194078e-21 ], "max":[ 0.00229191919789, 0.047907166183, 0.0691591277719, 0.0995827168226, 0.0820566862822, 0.0232072826475, 0.0308443717659, 0.0302771702409, 0.0344947054982, 0.0269099473953, 0.0134304724634, 0.00715320091695, 0.00838674418628, 0.0218261200935, 0.019664183259, 0.0362881943583, 0.0182446800172, 0.0173479039222, 0.0146563379094, 0.00540218688548, 0.00442544184625, 0.00217473763041, 0.0013186649885, 0.00208773952909, 0.00195873784833, 0.000584891589824, 0.00050722778542 ], "dvar2":[ 2.09710808718e-08, 5.00840687891e-05, 9.50076937443e-05, 0.000209878431633, 8.73877215781e-05, 3.97830672227e-06, 1.24859725474e-05, 1.86764350474e-06, 3.62891637451e-06, 3.01378167933e-06, 5.96735674208e-07, 1.57074438789e-07, 2.46402947823e-07, 4.87388831516e-07, 4.96838595154e-07, 1.52867119141e-06, 1.2727361991e-06, 3.34951494096e-07, 4.58745859078e-07, 1.02385989464e-07, 2.90899002664e-08, 3.47254207611e-08, 1.03255741735e-08, 3.98843491567e-09, 4.08773503935e-09, 1.11437570283e-09, 4.70612881998e-10 ], "median":[ 1.01329169411e-06, 0.000326245411998, 0.00187892094254, 0.00155476410873, 0.0016892075073, 0.000451811589301, 0.000548189680558, 0.000192572828382, 0.000307663634885, 0.000414336362155, 9.44759449339e-05, 2.73611021839e-05, 5.38471249456e-05, 6.47374472464e-05, 8.72917589732e-05, 0.000232024758589, 0.000200124268304, 0.000144036966958, 0.000164192693774, 8.08068361948e-05, 4.40170915681e-05, 1.11471890705e-05, 6.5616086431e-06, 3.2568784718e-06, 2.92447043648e-06, 4.78593108255e-07, 7.76246977807e-08 ], "dmean2":[ 5.63530775253e-05, 0.00340586039238, 0.00441808719188, 0.00632638018578, 0.0048057041131, 0.00103038607631, 0.00156902591698, 0.000596007099375, 0.000905426510144, 0.000906587869395, 0.000307931040879, 0.000146102538565, 0.000193360538105, 0.000240257213591, 0.000224526840611, 0.00052686111303, 0.000436997273937, 0.000281702930806, 0.000314143690048, 0.000150212654262, 8.88355425559e-05, 8.39097774588e-05, 4.62743046228e-05, 2.28419812629e-05, 2.21090576815e-05, 1.11555727926e-05, 5.03497130921e-06 ], "dmean":[ 4.36054288002e-05, 0.00207182555459, 0.00250360206701, 0.00372918182984, 0.0029777479358, 0.00065627245931, 0.000966914638411, 0.000378262484446, 0.000581912638154, 0.000613346113823, 0.000192098785192, 9.08574875211e-05, 0.000126861719764, 0.000160341092851, 0.000145415237057, 0.00034682394471, 0.000273586803814, 0.000176411645953, 0.000198787456611, 9.40177123994e-05, 5.56063205295e-05, 5.36341467523e-05, 3.11664407491e-05, 1.60014496942e-05, 1.57560625667e-05, 7.42050679037e-06, 3.44123100149e-06 ], "var":[ 1.73105885182e-08, 1.84976615856e-05, 2.50716802839e-05, 7.04820486135e-05, 4.76136665384e-05, 3.67056259165e-06, 6.74153125146e-06, 1.57238355314e-06, 3.58573174708e-06, 5.54312009626e-06, 5.51931407244e-07, 1.78970466891e-07, 4.17550438669e-07, 9.57703605309e-07, 8.59389047037e-07, 6.6176985456e-06, 1.22271069358e-06, 4.2584204607e-07, 5.58315321086e-07, 9.85875061588e-08, 2.47446703128e-08, 1.64356386279e-08, 7.04793423623e-09, 3.46884565516e-09, 3.82823417411e-09, 6.34027108593e-10, 3.8906944333e-10 ], "dvar":[ 1.15370024645e-08, 1.92330826394e-05, 3.36813718604e-05, 8.24065355118e-05, 3.870560613e-05, 1.55360748977e-06, 4.66681467515e-06, 7.47769490772e-07, 1.50442451741e-06, 1.46062268414e-06, 2.81505151634e-07, 6.65834889446e-08, 1.17960382795e-07, 2.50351632758e-07, 2.41593681949e-07, 8.20324487449e-07, 5.11046778229e-07, 1.56924727435e-07, 1.97066171381e-07, 4.1844121057e-08, 1.20861924913e-08, 1.45208129965e-08, 4.66174743252e-09, 1.88047488692e-09, 2.07477035552e-09, 5.05100294923e-10, 2.44846420916e-10 ], "mean":[ 5.19759996678e-05, 0.00199001817964, 0.00309076649137, 0.00392009504139, 0.0039071268402, 0.00123883492779, 0.00161516538355, 0.000634168100078, 0.00102682900615, 0.00122780457605, 0.000289549614536, 0.000142228382174, 0.000236654945184, 0.000305703491904, 0.000276061356999, 0.000822361966129, 0.000508801313117, 0.000334064679919, 0.000397378782509, 0.00017829038552, 8.88610738912e-05, 5.81649801461e-05, 3.79984667234e-05, 2.1309551812e-05, 2.22307517106e-05, 7.82890947448e-06, 3.57463068212e-06 ] }, "melbands_flatness_db":{ "min":0.00437760027125, "max":0.607957184315, "dvar2":0.00331180845387, "median":0.219427987933, "dmean2":0.0528259426355, "dmean":0.034728333354, "var":0.00492821913213, "dvar":0.00150177872274, "mean":0.230123117566 }, "melbands_skewness":{ "min":-2.44428515434, "max":15.3962888718, "dvar2":2.92571592331, "median":2.28337860107, "dmean2":1.4999755621, "dmean":0.987386882305, "var":4.25560426712, "dvar":1.34734094143, "mean":2.84234952927 }, "barkbands_skewness":{ "min":-6.20005989075, "max":18.9707603455, "dvar2":2.02687716484, "median":1.45346200466, "dmean2":1.15091514587, "dmean":0.750691831112, "var":2.42782378197, "dvar":0.866045475006, "mean":1.71697402 }, "silence_rate_60dB":{ "min":0, "max":1, "dvar2":0.0371209047735, "median":0, "dmean2":0.0337187945843, "dmean":0.0168575756252, "var":0.179377868772, "dvar":0.0165733974427, "mean":0.234251752496 }, "spectral_energyband_low":{ "min":4.89638611687e-23, "max":0.116083092988, "dvar2":0.000277574028587, "median":0.00443472480401, "dmean2":0.00833203457296, "dmean":0.00496239587665, "var":9.57977899816e-05, "dvar":0.000101656405604, "mean":0.00667642848566 }, "spectral_energyband_middle_low":{ "min":2.98093395713e-22, "max":0.171243280172, "dvar2":0.000569899275433, "median":0.0082081919536, "dmean2":0.0117948763072, "dmean":0.00737277884036, "var":0.000299356790492, "dvar":0.000242799738771, "mean":0.0127554573119 }, "melbands_kurtosis":{ "min":-1.94107413292, "max":380.680023193, "dvar2":1038.95019531, "median":7.13754844666, "dmean2":17.6919975281, "dmean":11.5336751938, "var":1275.55053711, "dvar":493.653686523, "mean":19.2873706818 }, "spectral_decrease":{ "min":-4.64023344193e-08, "max":6.78438804261e-19, "dvar2":6.20802821665e-17, "median":-4.53350113006e-09, "dmean2":4.26796598063e-09, "dmean":2.69736544212e-09, "var":3.69683427013e-17, "dvar":2.61311086979e-17, "mean":-5.59147705914e-09 }, "erbbands_kurtosis":{ "min":-1.86338639259, "max":171.201263428, "dvar2":23.2438850403, "median":-0.320037484169, "dmean2":2.39585089684, "dmean":1.52739417553, "var":35.0008544922, "dvar":11.0155954361, "mean":1.24987971783 }, "melbands_crest":{ "min":1.85922825336, "max":32.9627304077, "dvar2":27.1548709869, "median":12.5910797119, "dmean2":5.60343551636, "dmean":3.39730739594, "var":20.3299713135, "dvar":10.5175638199, "mean":13.3324451447 }, "melbands_spread":{ "min":0.26147004962, "max":299.135498047, "dvar2":1027.73010254, "median":17.4329528809, "dmean2":16.3484096527, "dmean":10.0459423065, "var":529.263366699, "dvar":403.605499268, "mean":23.2355747223 }, "spectral_energy":{ "min":1.02320440393e-20, "max":0.179453358054, "dvar2":0.000923860818148, "median":0.0185781233013, "dmean2":0.0165870357305, "dmean":0.0105188144371, "var":0.000572183460463, "dvar":0.00039060486597, "mean":0.0224339049309 }, "mfcc":{ "mean":[ -715.191650391, 133.878646851, -2.74888682365, 38.7127075195, 3.85699295998, -10.4443674088, -5.89105558395, 4.36424779892, 2.37372231483, 1.66640949249, -7.12301874161, -9.74494552612, -3.86338329315 ], "icov":[ [ 7.98077235231e-05, -5.86888636462e-05, 0.000145378129673, -9.86643281067e-05, 3.03972447e-05, -0.000187377940165, 0.000134168396471, -0.000129314183141, 1.27186794998e-05, -7.31398395146e-05, 1.38320649512e-06, 0.000111114335596, -8.96842757356e-05 ], [ -5.86888636462e-05, 0.000914695789106, -1.55892048497e-05, 0.000330161477905, -0.000239893066464, 0.00115360564087, -0.000499361369293, 0.000324924068991, -1.15215043479e-05, -0.000243278453127, 0.000191972227185, -9.64893956734e-07, 0.000270225456916 ], [ 0.000145378129673, -1.55892048497e-05, 0.0013757571578, 7.94086445239e-05, -0.000468786456622, -0.00135524733923, 0.00098228675779, -0.000316469959216, -0.000591932330281, 0.000769039965235, -0.000745072495192, 0.000638137804344, 0.000274067162536 ], [ -9.86643281067e-05, 0.000330161477905, 7.94086445239e-05, 0.00385635741986, -0.00140862353146, 0.000500513473526, 0.000532710750122, -0.00153480307199, 0.000852926750667, -0.000837227795273, 0.00151946756523, -0.00070260866778, 0.000143392870086 ], [ 3.03972447e-05, -0.000239893066464, -0.000468786456622, -0.00140862353146, 0.0056857005693, -0.00172490451951, -0.00111165409908, 0.000201825780096, 0.000247466086876, -0.00217686733231, 0.00112909392919, -0.000958321907092, -0.000166401950992 ], [ -0.000187377940165, 0.00115360564087, -0.00135524733923, 0.000500513473526, -0.00172490451951, 0.00837340857834, -0.00406587868929, 0.00216831197031, -0.0023798532784, 0.00346444058232, -0.00286303297617, 0.00123248388991, -0.000868651026394 ], [ 0.000134168396471, -0.000499361369293, 0.00098228675779, 0.000532710750122, -0.00111165409908, -0.00406587868929, 0.00986782740802, -0.00542975915596, 0.00409825751558, -0.0044681020081, 0.00282385596074, -0.00354772782885, 0.00168551225215 ], [ -0.000129314183141, 0.000324924068991, -0.000316469959216, -0.00153480307199, 0.000201825780096, 0.00216831197031, -0.00542975915596, 0.0144132943824, -0.00864001736045, 0.00550767453387, -0.00397388311103, 0.00314126117155, -0.00206855195574 ], [ 1.27186794998e-05, -1.15215043479e-05, -0.000591932330281, 0.000852926750667, 0.000247466086876, -0.0023798532784, 0.00409825751558, -0.00864001736045, 0.0176015142351, -0.0112102692947, 0.0083336783573, -0.00373222492635, 0.00208288733847 ], [ -7.31398395146e-05, -0.000243278453127, 0.000769039965235, -0.000837227795273, -0.00217686733231, 0.00346444058232, -0.0044681020081, 0.00550767453387, -0.0112102692947, 0.0205363947898, -0.0105180032551, 0.00535045098513, -0.00213157944381 ], [ 1.38320649512e-06, 0.000191972227185, -0.000745072495192, 0.00151946756523, 0.00112909392919, -0.00286303297617, 0.00282385596074, -0.00397388311103, 0.0083336783573, -0.0105180032551, 0.0160211343318, -0.00672314595431, 0.00358490552753 ], [ 0.000111114335596, -9.64893956734e-07, 0.000638137804344, -0.00070260866778, -0.000958321907092, 0.00123248388991, -0.00354772782885, 0.00314126117155, -0.00373222492635, 0.00535045098513, -0.00672314595431, 0.0181408431381, -0.00800348073244 ], [ -8.96842757356e-05, 0.000270225456916, 0.000274067162536, 0.000143392870086, -0.000166401950992, -0.000868651026394, 0.00168551225215, -0.00206855195574, 0.00208288733847, -0.00213157944381, 0.00358490552753, -0.00800348073244, 0.0156487096101 ] ], "cov":[ [ 18717.6230469, 1395.88500977, -2566.72802734, 557.851989746, -126.109024048, -374.194091797, 26.1053237915, 164.080841064, 80.1878204346, 192.214401245, -182.145751953, -47.5592041016, 152.686767578 ], [ 1395.88500977, 1649.59350586, -550.320007324, -49.1516876221, -56.1948204041, -331.535217285, 9.86240959167, -34.6940498352, -6.41311454773, 74.1020736694, -56.9916381836, -16.3797187805, -19.4202880859 ], [ -2566.72802734, -550.320007324, 1546.81799316, -94.7429580688, 133.984313965, 339.89239502, -54.6255912781, 10.0923881531, 34.4428405762, -69.080619812, 85.0990753174, -36.5973091125, -56.1265907288 ], [ 557.851989746, -49.1516876221, -94.7429580688, 342.6902771, 83.1379013062, -15.0537748337, 17.4024486542, 46.2917900085, 21.9955101013, 12.8328580856, -40.3507461548, 8.21014022827, 19.1214065552 ], [ -126.109024048, -56.1948204041, 133.984313965, 83.1379013062, 261.195220947, 98.0570144653, 69.749458313, 21.4776115417, 23.9828796387, 28.7067451477, 2.99768400192, 18.3751049042, 10.1154251099 ], [ -374.194091797, -331.535217285, 339.89239502, -15.0537748337, 98.0570144653, 288.139526367, 71.6913833618, 15.3370637894, 9.08637428284, -21.2720279694, 42.7490844727, 11.0748538971, 0.872315227985 ], [ 26.1053237915, 9.86240959167, -54.6255912781, 17.4024486542, 69.749458313, 71.6913833618, 187.006271362, 49.2006340027, 4.36477804184, 25.2697525024, 7.5669798851, 29.0018577576, 7.81967544556 ], [ 164.080841064, -34.6940498352, 10.0923881531, 46.2917900085, 21.4776115417, 15.3370637894, 49.2006340027, 122.378990173, 54.7077331543, 6.68141078949, -6.7650809288, -0.911539852619, 7.60771179199 ], [ 80.1878204346, -6.41311454773, 34.4428405762, 21.9955101013, 23.9828796387, 9.08637428284, 4.36477804184, 54.7077331543, 120.954666138, 41.4050827026, -25.8017272949, -5.81494665146, -0.236202299595 ], [ 192.214401245, 74.1020736694, -69.080619812, 12.8328580856, 28.7067451477, -21.2720279694, 25.2697525024, 6.68141078949, 41.4050827026, 102.835891724, 31.3814048767, -2.45175004005, -1.74627566338 ], [ -182.145751953, -56.9916381836, 85.0990753174, -40.3507461548, 2.99768400192, 42.7490844727, 7.5669798851, -6.7650809288, -25.8017272949, 31.3814048767, 120.884010315, 22.8159122467, -8.79971408844 ], [ -47.5592041016, -16.3797187805, -36.5973091125, 8.21014022827, 18.3751049042, 11.0748538971, 29.0018577576, -0.911539852619, -5.81494665146, -2.45175004005, 22.8159122467, 87.9682235718, 38.346157074 ], [ 152.686767578, -19.4202880859, -56.1265907288, 19.1214065552, 10.1154251099, 0.872315227985, 7.81967544556, 7.60771179199, -0.236202299595, -1.74627566338, -8.79971408844, 38.346157074, 87.662071228 ] ] }, "spectral_contrast_valleys":{ "min":[ -27.644701004, -27.7437572479, -27.5804691315, -27.6421985626, -27.3466243744, -27.4174346924 ], "max":[ -5.21189641953, -4.43200492859, -5.37789392471, -5.91939544678, -5.84870004654, -8.253657341 ], "dvar2":[ 0.418128162622, 0.42821636796, 0.406443417072, 0.387645244598, 0.499291837215, 0.697984993458 ], "median":[ -7.62850189209, -6.92876195908, -7.67359733582, -8.07751655579, -7.46400737762, -11.0671672821 ], "dmean2":[ 0.747626721859, 0.710296332836, 0.67908090353, 0.625626146793, 0.595694363117, 0.680769026279 ], "dmean":[ 0.493877381086, 0.47190451622, 0.458809673786, 0.433981686831, 0.418061226606, 0.529967188835 ], "var":[ 2.56318879128, 2.56686043739, 2.68657803535, 2.71048474312, 2.86386156082, 2.03321886063 ], "dvar":[ 0.185303419828, 0.210249379277, 0.205063581467, 0.197276696563, 0.240655809641, 0.327577888966 ], "mean":[ -7.9825963974, -7.30778980255, -8.13263988495, -8.62220096588, -8.10116863251, -11.1167650223 ] }, "barkbands_flatness_db":{ "min":0.00845116842538, "max":0.463767468929, "dvar2":0.00153901893646, "median":0.152025014162, "dmean2":0.0385256558657, "dmean":0.0259312130511, "var":0.00293617043644, "dvar":0.000700293399859, "mean":0.161552548409 }, "dynamic_complexity":5.97568511963, "spectral_skewness":{ "min":-0.258594423532, "max":7.78090715408, "dvar2":0.501113593578, "median":1.41668355465, "dmean2":0.598508477211, "dmean":0.382409095764, "var":0.599681735039, "dvar":0.224964693189, "mean":1.57063114643 }, "erbbands_flatness_db":{ "min":0.0321905463934, "max":0.434577912092, "dvar2":0.00118384102825, "median":0.181439816952, "dmean2":0.0333808884025, "dmean":0.024810899049, "var":0.00294912024401, "dvar":0.000594827288296, "mean":0.178649738431 }, "hfc":{ "min":1.12110872149e-16, "max":96.712890625, "dvar2":109.576454163, "median":11.9836702347, "dmean2":6.97959280014, "dmean":4.67007303238, "var":197.084396362, "dvar":50.9854736328, "mean":14.6673564911 }, "barkbands_crest":{ "min":2.1863629818, "max":25.4534358978, "dvar2":11.5983400345, "median":8.56367301941, "dmean2":3.74820661545, "dmean":2.30216050148, "var":11.5966396332, "dvar":4.54739236832, "mean":9.14883136749 } }, "highlevel":{ "timbre":{ "all":{ "dark":0.0808309540153, "bright":0.919169068336 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"bright", "probability":0.919169068336 }, "ismir04_rhythm":{ "all":{ "Rumba-American":0.0406456775963, "VienneseWaltz":0.338310062885, "Samba":0.0297329928726, "Rumba-Misc":0.0135653112084, "Rumba-International":0.0278510767967, "Tango":0.330144882202, "Waltz":0.00898563489318, "ChaChaCha":0.0889096781611, "Jive":0.11033257097, "Quickstep":0.0115221142769 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"VienneseWaltz", "probability":0.338310062885 }, "voice_instrumental":{ "all":{ "instrumental":0.999981045723, "voice":1.89501206478e-05 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"instrumental", "probability":0.999981045723 }, "gender":{ "all":{ "male":0.108683988452, "female":0.891315996647 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"female", "probability":0.891315996647 }, "genre_rosamerica":{ "all":{ "hip":0.070330247283, "rhy":0.225707545877, "jaz":0.0771619826555, "dan":0.0574826933444, "roc":0.270465612411, "cla":0.0938607081771, "pop":0.175827592611, "spe":0.0291636306792 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"roc", "probability":0.270465612411 }, "mood_electronic":{ "all":{ "electronic":0.339881360531, "not_electronic":0.660118639469 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_electronic", "probability":0.660118639469 }, "genre_electronic":{ "all":{ "house":0.187250360847, "trance":0.185409858823, "dnb":0.00702595943585, "techno":0.0184047427028, "ambient":0.601909101009 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"ambient", "probability":0.601909101009 }, "mood_sad":{ "all":{ "not_sad":0.700305402279, "sad":0.299694597721 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_sad", "probability":0.700305402279 }, "tonal_atonal":{ "all":{ "atonal":0.125749841332, "tonal":0.874250173569 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"tonal", "probability":0.874250173569 }, "mood_party":{ "all":{ "party":0.234383180737, "not_party":0.765616834164 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_party", "probability":0.765616834164 }, "moods_mirex":{ "all":{ "Cluster2":0.0673071071506, "Cluster3":0.397048592567, "Cluster1":0.061667304486, "Cluster4":0.190215244889, "Cluster5":0.283761769533 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"Cluster3", "probability":0.397048592567 }, "danceability":{ "all":{ "danceable":0.143928021193, "not_danceable":0.85607200861 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_danceable", "probability":0.85607200861 }, "genre_dortmund":{ "all":{ "raphiphop":4.74844455312e-05, "electronic":0.984485208988, "jazz":0.000787914788816, "pop":0.000125292601297, "folkcountry":0.00235203420743, "rock":0.0010081063956, "alternative":0.00961782038212, "funksoulrnb":6.0458383814e-05, "blues":0.00151570443995 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"electronic", "probability":0.984485208988 }, "mood_acoustic":{ "all":{ "acoustic":0.415711194277, "not_acoustic":0.584288835526 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_acoustic", "probability":0.584288835526 }, "mood_happy":{ "all":{ "not_happy":0.910523295403, "happy":0.0894767045975 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_happy", "probability":0.910523295403 }, "mood_aggressive":{ "all":{ "not_aggressive":0.922077834606, "aggressive":0.0779221653938 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_aggressive", "probability":0.922077834606 }, "genre_tzanetakis":{ "all":{ "hip":0.154464527965, "jaz":0.308918893337, "bl":0.0514711923897, "roc":0.0772226303816, "cla":0.0343172624707, "pop":0.0617748275399, "met":0.0441242903471, "co":0.102957598865, "reg":0.0617764480412, "dis":0.102972343564 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"jaz", "probability":0.308918893337 }, "mood_relaxed":{ "all":{ "not_relaxed":0.87636756897, "relaxed":0.123632438481 }, "version":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" }, "value":"not_relaxed", "probability":0.87636756897 } }, "metadata":{ "audio_properties":{ "analysis_sample_rate":44100, "length":214.866668701, "downmix":"mix", "bit_rate":0, "codec":"flac", "md5_encoded":"2b46dab358c1b79a3decd5bd93d7221f", "equal_loudness":0, "replay_gain":-14.4778690338, "lossless":1 }, "version":{ "lowlevel":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "essentia_build_sha":"50a0fbec89d6a9cedea3d45b6611406f7e8c7b1a", "essentia_git_sha":"v2.1_beta1-7-ge0e83e8-dirty" }, "highlevel":{ "essentia":"2.1-beta1", "extractor":"music 1.0", "gaia_git_sha":"857329b", "models_essentia_git_sha":"v2.1_beta1", "essentia_git_sha":"v2.1_beta1-228-g260734a", "essentia_build_sha":"8e24b98b71ad84f3024c7541412f02124a26d327", "gaia":"2.4-dev" } }, "tags":{ "albumartistsort":[ "Various Artists" ], "disctotal":[ "1" ], "file_name":"04 La Grange.flac", "artists":[ "ZZ Top" ], "musicbrainz_workid":[ "42722fe8-9de7-3729-a506-3c7f41c617a9" ], "releasecountry":[ "DE" ], "totaldiscs":[ "1" ], "albumartist":[ "Various Artists" ], "musicbrainz_albumartistid":[ "89ad4ac3-39f7-470e-963a-56509c546377" ], "composer":[ "Dusty Hill", "Frank Beard", "Billy Gibbons" ], "catalognumber":[ "491384 2" ], "tracknumber":[ "4" ], "replaygain_track_peak":[ "0.999969" ], "engineer":[ "Terry Manning", "Robin Hood Brians" ], "album":[ "Armageddon:The Album" ], "asin":[ "B000024C3A" ], "replaygain_album_gain":[ "-9.32 dB" ], "musicbrainz_artistid":[ "a81259a0-a2f5-464b-866e-71220f2739f1" ], "producer":[ "Bill Ham" ], "script":[ "Latn" ], "media":[ "CD" ], "label":[ "Columbia" ], "artistsort":[ "ZZ Top" ], "acoustid_id":[ "3ed3441e-facc-4fcd-9ef7-9fbc68c206a2" ], "replaygain_album_peak":[ "0.999969" ], "lyricist":[ "Dusty Hill", "Frank Beard", "Billy Gibbons" ], "musicbrainz_releasegroupid":[ "f51d56e4-0211-3533-a9a5-08c02d8bb04a" ], "compilation":[ "1" ], "barcode":[ "5099749138421" ], "releasestatus":[ "official" ], "composersort":[ "Hill, Dusty", "Beard, Frank", "Gibbons, Billy" ], "date":[ "1998" ], "isrc":[ "USWB10505222" ], "discnumber":[ "1" ], "musicbrainz_recordingid":[ "cd3f5efa-bc5e-4064-a765-960494ad4bb4" ], "tracktotal":[ "14" ], "originaldate":[ "1998-06-23" ], "language":[ "eng" ], "artist":[ "ZZ Top" ], "title":[ "La Grange" ], "releasetype":[ "album", "soundtrack" ], "musicbrainz_albumid":[ "cfc31187-aebd-309f-a92f-7138c17df7c2" ], "work":[ "La Grange" ], "totaltracks":[ "14" ], "replaygain_track_gain":[ "-9.38 dB" ], "musicbrainz_releasetrackid":[ "befe2741-462b-3568-ba06-c8cc8e4f6eaf" ] } } } ================================================ FILE: test/rsrc/beetsplug/test.py ================================================ from beets import ui from beets.plugins import BeetsPlugin class TestPlugin(BeetsPlugin): def __init__(self): super().__init__() self.is_test_plugin = True def commands(self): test = ui.Subcommand("test") test.func = lambda *args: None # Used in CompletionTest test.parser.add_option("-o", "--option", dest="my_opt") plugin = ui.Subcommand("plugin") plugin.func = lambda *args: None return [test, plugin] ================================================ FILE: test/rsrc/convert_stub.py ================================================ #!/usr/bin/env python3 """A tiny tool used to test the `convert` plugin. It copies a file and appends a specified text tag. """ import sys def convert(in_file, out_file, tag): """Copy `in_file` to `out_file` and append the string `tag`.""" if not isinstance(tag, bytes): tag = tag.encode("utf-8") with open(out_file, "wb") as out_f: with open(in_file, "rb") as in_f: out_f.write(in_f.read()) out_f.write(tag) if __name__ == "__main__": convert(sys.argv[1], sys.argv[2], sys.argv[3]) ================================================ FILE: test/rsrc/itunes_library_unix.xml ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Major Version</key><integer>1</integer> <key>Minor Version</key><integer>1</integer> <key>Date</key><date>2015-05-08T14:36:28Z</date> <key>Application Version</key><string>12.1.2.27</string> <key>Features</key><integer>5</integer> <key>Show Content Ratings</key><true/> <key>Music Folder</key><string>file:////Music/</string> <key>Library Persistent ID</key><string>1ABA8417E4946A32</string> <key>Tracks</key> <dict> <key>634</key> <dict> <key>Track ID</key><integer>634</integer> <key>Name</key><string>Tessellate</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Genre</key><string>Alternative</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>5525212</integer> <key>Total Time</key><integer>182674</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>3</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-02-02T15:23:08Z</date> <key>Date Added</key><date>2014-04-24T09:28:38Z</date> <key>Bit Rate</key><integer>238</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>0</integer> <key>Play Date</key><integer>3513593824</integer> <key>Skip Count</key><integer>3</integer> <key>Skip Date</key><date>2015-02-05T15:41:04Z</date> <key>Rating</key><integer>80</integer> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>20E89D1580C31363</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file:///Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3</string> <key>File Folder Count</key><integer>4</integer> <key>Library Folder Count</key><integer>2</integer> </dict> <key>636</key> <dict> <key>Track ID</key><integer>636</integer> <key>Name</key><string>Breezeblocks</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Genre</key><string>Alternative</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>6827195</integer> <key>Total Time</key><integer>227082</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>4</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-02-02T15:23:08Z</date> <key>Date Added</key><date>2014-04-24T09:28:38Z</date> <key>Bit Rate</key><integer>237</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>31</integer> <key>Play Date</key><integer>3513594051</integer> <key>Play Date UTC</key><date>2015-05-04T12:20:51Z</date> <key>Skip Count</key><integer>0</integer> <key>Rating</key><integer>100</integer> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>D7017B127B983D38</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file://localhost/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3</string> <key>File Folder Count</key><integer>4</integer> <key>Library Folder Count</key><integer>2</integer> </dict> <key>638</key> <dict> <key>Track ID</key><integer>638</integer> <key>Name</key><string>❦ (Ripe & Ruin)</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>2173293</integer> <key>Total Time</key><integer>72097</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>2</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-05-09T17:04:53Z</date> <key>Date Added</key><date>2015-02-02T15:28:39Z</date> <key>Bit Rate</key><integer>233</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>8</integer> <key>Play Date</key><integer>3514109973</integer> <key>Play Date UTC</key><date>2015-05-10T11:39:33Z</date> <key>Skip Count</key><integer>1</integer> <key>Skip Date</key><date>2015-02-02T15:29:10Z</date> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>183699FA0554D0E6</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file:///Music/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3</string> <key>File Folder Count</key><integer>4</integer> <key>Library Folder Count</key><integer>2</integer> </dict> </dict> <key>Playlists</key> <array> <dict> <key>Name</key><string>Library</string> <key>Master</key><true/> <key>Playlist ID</key><integer>11480</integer> <key>Playlist Persistent ID</key><string>CD6FF684E7A6A166</string> <key>Visible</key><false/> <key>All Items</key><true/> <key>Playlist Items</key> <array> <dict> <key>Track ID</key><integer>634</integer> </dict> <dict> <key>Track ID</key><integer>636</integer> </dict> <dict> <key>Track ID</key><integer>638</integer> </dict> </array> </dict> <dict> <key>Name</key><string>Music</string> <key>Playlist ID</key><integer>16906</integer> <key>Playlist Persistent ID</key><string>4FB2E64E0971DD45</string> <key>Distinguished Kind</key><integer>4</integer> <key>Music</key><true/> <key>All Items</key><true/> <key>Playlist Items</key> <array> <dict> <key>Track ID</key><integer>634</integer> </dict> <dict> <key>Track ID</key><integer>636</integer> </dict> <dict> <key>Track ID</key><integer>638</integer> </dict> </array> </dict> </array> </dict> </plist> ================================================ FILE: test/rsrc/itunes_library_windows.xml ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Major Version</key><integer>1</integer> <key>Minor Version</key><integer>1</integer> <key>Date</key><date>2015-05-11T15:27:14Z</date> <key>Application Version</key><string>12.1.2.27</string> <key>Features</key><integer>5</integer> <key>Show Content Ratings</key><true/> <key>Music Folder</key><string>file://localhost/C:/Documents%20and%20Settings/Owner/My%20Documents/My%20Music/iTunes/iTunes%20Media/</string> <key>Library Persistent ID</key><string>B4C9F3EE26EFAF78</string> <key>Tracks</key> <dict> <key>180</key> <dict> <key>Track ID</key><integer>180</integer> <key>Name</key><string>Tessellate</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Genre</key><string>Alternative</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>5525212</integer> <key>Total Time</key><integer>182674</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>3</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-02-02T15:23:08Z</date> <key>Date Added</key><date>2014-04-24T09:28:38Z</date> <key>Bit Rate</key><integer>238</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>0</integer> <key>Play Date</key><integer>3513593824</integer> <key>Skip Count</key><integer>3</integer> <key>Skip Date</key><date>2015-02-05T15:41:04Z</date> <key>Rating</key><integer>80</integer> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>20E89D1580C31363</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3</string> <key>File Folder Count</key><integer>-1</integer> <key>Library Folder Count</key><integer>-1</integer> </dict> <key>183</key> <dict> <key>Track ID</key><integer>183</integer> <key>Name</key><string>Breezeblocks</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Genre</key><string>Alternative</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>6827195</integer> <key>Total Time</key><integer>227082</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>4</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-02-02T15:23:08Z</date> <key>Date Added</key><date>2014-04-24T09:28:38Z</date> <key>Bit Rate</key><integer>237</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>31</integer> <key>Play Date</key><integer>3513594051</integer> <key>Play Date UTC</key><date>2015-05-04T12:20:51Z</date> <key>Skip Count</key><integer>0</integer> <key>Rating</key><integer>100</integer> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>D7017B127B983D38</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3</string> <key>File Folder Count</key><integer>-1</integer> <key>Library Folder Count</key><integer>-1</integer> </dict> <key>638</key> <dict> <key>Track ID</key><integer>638</integer> <key>Name</key><string>❦ (Ripe & Ruin)</string> <key>Artist</key><string>alt-J</string> <key>Album Artist</key><string>alt-J</string> <key>Album</key><string>An Awesome Wave</string> <key>Kind</key><string>MPEG audio file</string> <key>Size</key><integer>2173293</integer> <key>Total Time</key><integer>72097</integer> <key>Disc Number</key><integer>1</integer> <key>Disc Count</key><integer>1</integer> <key>Track Number</key><integer>2</integer> <key>Track Count</key><integer>13</integer> <key>Year</key><integer>2012</integer> <key>Date Modified</key><date>2015-05-09T17:04:53Z</date> <key>Date Added</key><date>2015-02-02T15:28:39Z</date> <key>Bit Rate</key><integer>233</integer> <key>Sample Rate</key><integer>44100</integer> <key>Play Count</key><integer>8</integer> <key>Play Date</key><integer>3514109973</integer> <key>Play Date UTC</key><date>2015-05-10T11:39:33Z</date> <key>Skip Count</key><integer>1</integer> <key>Skip Date</key><date>2015-02-02T15:29:10Z</date> <key>Album Rating</key><integer>80</integer> <key>Album Rating Computed</key><true/> <key>Artwork Count</key><integer>1</integer> <key>Sort Album</key><string>Awesome Wave</string> <key>Sort Artist</key><string>alt-J</string> <key>Persistent ID</key><string>183699FA0554D0E6</string> <key>Track Type</key><string>File</string> <key>Location</key><string>file://localhost/G:/Experiments/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3</string> <key>File Folder Count</key><integer>4</integer> <key>Library Folder Count</key><integer>2</integer> </dict> </dict> <key>Playlists</key> <array> <dict> <key>Name</key><string>Bibliotheek</string> <key>Master</key><true/> <key>Playlist ID</key><integer>72</integer> <key>Playlist Persistent ID</key><string>728AA5B1D00ED23B</string> <key>Visible</key><false/> <key>All Items</key><true/> <key>Playlist Items</key> <array> <dict> <key>Track ID</key><integer>180</integer> </dict> <dict> <key>Track ID</key><integer>183</integer> </dict> <dict> <key>Track ID</key><integer>638</integer> </dict> </array> </dict> <dict> <key>Name</key><string>Muziek</string> <key>Playlist ID</key><integer>103</integer> <key>Playlist Persistent ID</key><string>8120A002B0486AD7</string> <key>Distinguished Kind</key><integer>4</integer> <key>Music</key><true/> <key>All Items</key><true/> <key>Playlist Items</key> <array> <dict> <key>Track ID</key><integer>180</integer> </dict> <dict> <key>Track ID</key><integer>183</integer> </dict> <dict> <key>Track ID</key><integer>638</integer> </dict> </array> </dict> </array> </dict> </plist> ================================================ FILE: test/rsrc/lyrics/examplecom/beetssong.txt ================================================ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <title>John Doe - beets song Lyrics

beets song Lyrics



John Doe beets song lyrics
Lyrics search for Artist - Song:

Back to the: Music Lyrics > John Doe lyrics > beets song lyrics

John Doe
beets song lyrics

Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Beets is the media library management system for obsessive music geeks.
The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a suite of tools for manipulating and accessing your music.
Here's an example of beets' brainy tag corrector doing its thing: Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via plugins, beets becomes a panacea
Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Share beets song lyrics

  RATE THIS SONG!

Add to Favorites Lyrics Email to a Friend John Doe - beets song Lyrics
Rating:

Use the following form to post your meaning of this song, rate it, or submit comments about this song.


0
Name:
Comment:
Type maps backwards (spam prevention):


There are no comments for this song yet.
ToneFuse Music

================================================ FILE: test/rsrc/lyrics/geniuscom/2pacalleyezonmelyrics.txt ================================================ 2Pac – All Eyez On Me Lyrics | Genius Lyrics

All Eyez On Me Lyrics

[Intro: 2Pac]
Big Syke, 'Nook, Hank, Bogart, Big Sur (yeah)
Y'all know how this shit go (you know)
All eyes on me
Motherfuckin' OG
Roll up in the club and shit, is that right?
All eyes on me
All eyes on me
But you know what?

[Verse 1: 2Pac]
I bet you got it twisted, you don't know who to trust
So many player-hatin' niggas tryna sound like us

Say they ready for the funk, but I don't think they knowin'
Straight to the depths of Hell is where those cowards goin'
Well, are you still down? Nigga, holla when you see me

And let these devils be sorry for the day they finally freed me
I got a caravan of niggas every time we ride
Hittin' motherfuckers up when we pass by
Until I die, live the life of a boss player 'cause even when I'm high
Fuck with me and get crossed later, the futures in my eyes
'cause all I want is cash and things
A five-double-0 Benz, flauntin' flashy rings, uhh
Bitches pursue me like a dream
Been known to disappear before your eyes just like a dope fiend
It seems, my main thing was to be major paid
The game sharper than a motherfuckin' razor blade
Say money bring bitches and bitches bring lies
One nigga's gettin' jealous and motherfuckers died
Depend on me like the first and fifteenth
They might hold me for a second, but these punks won't get me

We got foe niggas and low riders in ski masks
Screamin', "Thug Life!" every time they pass, all eyes on me
[Chorus: 2Pac]
Live the life of a thug nigga until the day I die
Live the life of a boss player (All eyes on me) 'cause even gettin' high
All eyes on me
Live the life of a thug nigga until the day I die
Live the life of a boss player 'cause even gettin' high

[Interlude: Big Syke]
Hey, to my nigga Pac

[Verse 2: Big Syke]
So much trouble in the world, nigga
Can't nobody feel your pain
The world's changin' every day, time's movin' fast
My girl said I need a raise, how long will she last?
I'm caught between my woman and my pistol and my chips
Triple beam, got some smokers on, whistle as I dip
I'm lost in the land with no plan, livin' life flawless
Crime boss, contraband, let me toss this
Mediocres got a lot of nerve, let my bucket swerve
I'm takin' off from the curb
The nervousness neglect make me pack a TEC
Devoted to servin' this Moët and pay checks
Like Akai satellite, nigga, I'm forever ballin'
It ain't right: parasites, triggers, and fleas crawlin'
Sucker, duck and get busted, no emotion
My devotion is handlin' my business, nigga, keep on coastin'
Where you goin', I been there, came back as lonely, homie
Steady flowin' against the grain, niggas still don't know me
It's about the money in this rap shit, this crap shit
It ain't funny, niggas don't even know how to act, shit
What can I do? What can I say? Is there another way?
Blunts and gin all day, 24 parlay
My little homie G, can't you see I'm buster-free?
Niggas can't stand me; all eyes on me
[Chorus: 2Pac]
Live the life of a thug nigga until the day I die
Live the life of a boss player 'cause even gettin' high
All eyes on me
All eyes on me
Live the life of a thug nigga until the day I die
Live the life of a boss player 'cause even gettin' high
All eyes on me

[Verse 3: 2Pac]
The feds is watchin', niggas plottin' to get me
Will I survive? Will I die? Come on, let's picture the possibility
Givin' me charges, lawyers makin' a grip
I told the judge I was raised wrong and that's why I blaze shit
Was hyper as a kid, cold as a teenager
On my mobile, callin' big shots on the scene major
Packin' hundreds in my drawers, fuck the law
Bitches, I fuck with a passion, I'm livin' rough and raw
Catchin' cases at a fast rate, ballin' in the fast lane
Hustle 'til the mornin', never stopped until the cash came
Live my life as a thug nigga until the day I die
Live my life as a boss player, 'cause even gettin' high
These niggas got me tossin' shit
I put the top down, now it's time to floss my shit
Keep your head up, nigga, make these motherfuckers suffer
Up in the Benz, burnin' rubber
The money is mandatory, the hoes is for the stress
This criminal lifestyle, equipped with the bulletproof vest
Make sure your eyes is on the meal ticket
Get your money, motherfucker, let's get rich and we'll kick it
All eyes on me
[Chorus: 2Pac]
Live the life as a thug nigga until the day I die
Live the life as a boss player 'cause even gettin' high
All eyes on me
All eyes on me
Live the life of a thug nigga until the day I die
Live the life of a boss player 'cause even gettin' high
All eyes on me

[Outro: 2Pac]
Pay attention, my niggas! See how that shit go?
Nigga walk up in this motherfucker and it be like, "Bing!"
Cops, bitches, every-motherfuckin'-body

(Live my life as a thug nigga until the day I die)
(Live my life as a boss playa, 'cause even gettin' high)
I got bustas, hoes, and police watchin' a nigga, you know?
(I live my life as a thug nigga until the day I die)
(Livin' life as a boss playa, 'cause even gettin' high)
He he he, it's like what they think
I'm walkin' around with some ki's in my pocket or somethin'
They think I'm goin' back to jail, they really on that dope
(Live my life as a thug nigga until the day I die)
(Live my life as a boss playa)
I know y'all watchin', I know y'all got me in the scopes
(Live my life as a thug nigga until the day I die)
(Live my life as a boss playa, 'cause even gettin' high)
I know y'all know this is Thug Life, baby!
Y'all got me under surveillance, huh?
All eyes on me, but I'm knowin'

How to Format Lyrics:

  • Type out all lyrics, even repeating song parts like the chorus
  • Lyrics should be broken down into individual lines
  • Use section headers above different song parts like [Verse], [Chorus], etc.
  • Use italics (<i>lyric</i>) and bold (<b>lyric</b>) to distinguish between different vocalists in the same song part
  • If you don’t understand a lyric, use [?]

To learn more, check out our transcription guide or visit our transcribers forum

About

Genius Annotation

The title track off of 2Pac’s album All Eyez on Me samples Linda Clifford’s “Never Gonna Stop” (also used for Nas' “Street Dreams,” which was released a few months later in July 1996). Producer Johnny J recalls connecting with 2Pac for this track:

That was the very first track I laid when we got together at Death Row. When he just got out of jail, just got released, two days later he’s like, “‘J’, get to the studio, I’m with Death Row now.” I assumed it was a joke, somebody perpetrating Tupac. I’m like “Hell no – ‘Pac is locked up!” He’s like “J, I’m out” I walk in, 15 minutes into the session, the first beat I put in the drum machine is “All Eyez On Me.” I wasn’t going to show him the track, honestly. I was like, “This track? Nah, it’s not finished. It’s incomplete.” My wife says, “Hey, it’s a dope beat!” So I just pop it in, titles just come right off his fuckin’ head.

This classic gives us an idea of what the media was doing with Pac’s life. At this moment, all eyes in the music world were on him due to the intrigue around his release from prison, signing with the notorious Death Row Records, as well as the 2Pac/Death Row/West Coast vs. Biggie/Bad Boy/East Coast beef.

Q&A

Find answers to frequently asked questions about the song and explore its deeper meaning

What did 2Pac say about "All Eyez On Me"?
Genius Answer

Kendrick on the Impact of Tupacs All Eyez on Me and California Love Video Shoot

“I was 8 yrs old when I first saw you. I couldn’t describe how I felt at that moment. So many emotions. Full of excitement. Full of joy and eagerness. 20 yrs later I understand exactly what that feeling was. INSPIRED,” Lamar wrote (via Pitchfork). “The people that you touched on that small intersection changed lives forever. I told myself I wanted to be a voice for man one day. Whoever knew I was speaking out loud for u to listen. Thank you, K.L.”

As Lamar told Rolling Stone, he was eight years old when he sat atop his father’s shoulder and witnessed Tupac and Dr. Dre film the video for “California Love” at the Compton Swap Meet. “I want to say they were in a white Bentley,” Lamar said. “These motorcycle cops trying to conduct traffic but one almost scraped the car, and Pac stood up on the passenger seat, like, ‘Yo, what the fuck!’ Yelling at the police, just like on his motherfucking songs. He gave us what we wanted.” Lamar would later shoot a scene at that same swap meet for his “King Kunta” music video, a nod to 2Pac.

Credits
Featuring
Producer
Phonographic Copyright ℗
Mixing Engineer
Assistant Engineer
Mastering Engineer
Recorded At
Can-Am Studios (Tarzana, CA)
Release Date
February 13, 1996
View All Eyez On Me samples
Tags
Comments
================================================ FILE: test/rsrc/lyrics/geniuscom/Ttngchinchillalyrics.txt ================================================ TTNG – Chinchilla Lyrics | Genius Lyrics

🚧  The new song page is now the default experience! We need your help to continue improving contributor features.  🚧

So far we've lost focus
Let's just concentrate on words that could mean everything

On nights like this
We drink ourselves dry
And make promises
Without intention

So fortunate that this was brought up
The last time. As I recall
I can’t hold up your every expectation

On nights like this
We drink ourselves dry
And make promises
Without intention

My God, is this what we’ve become?
Living parodies of love and loss
Can we really be all that lost?

So fortunate that this was brought up
The last time. As I recall
I can’t hold up your every expectation

One moment to another I am restless
Seems making love forever can often risk your heart
And I cannot remember when I was this messed up
In service of another I am beautiful
How to Format Lyrics:
  • Type out all lyrics, even if it’s a chorus that’s repeated throughout the song
  • The Section Header button breaks up song sections. Highlight the text then click the link
  • Use Bold and Italics only to distinguish between different singers in the same verse.
    • E.g. “Verse 1: Kanye West, Jay-Z, Both
  • Capitalize each line
  • To move an annotation to different lyrics in the song, use the [...] menu to switch to referent editing mode

About

This song bio is unreviewed
Genius Annotation

This song is about those relationships with a lot of fights and reconciliations. The singer and his couple are aruging/reconciliating, telling themselves everything is going to be better and things will change for good, specially when they get drunk, just to fight and reconciliate over and over again.

Ask us a question about this song
No questions asked yet
Credits
Written By
Stuart Smith
Release Date
October 13, 2008
Tags
Comments
Add a comment
Get the conversation started
Be the first to comment
================================================ FILE: test/rsrc/lyrics/geniuscom/sample.txt ================================================ SAMPLE – SONG Lyrics | g-example Lyrics
#

SONG

SAMPLE

SONG Lyrics

!!!! MISSING LYRICS HERE !!!
More on g-example
================================================ FILE: test/rsrc/lyrics/tekstowopl/piosenka24kgoldncityofangels1.txt ================================================ 24kGoldn - City Of Angels - tekst i tłumaczenie piosenki na Tekstowo.pl
2 170 744 tekstów, 20 217 poszukiwanych i 501 oczekujących

24kGoldn - City Of Angels

Tekst dodał(a): asdfghjklmnop Edytuj tekst
Tłumaczenie dodał(a): tapcapslock Edytuj tłumaczenie
Teledysk dodał(a): olcia_197 Edytuj teledysk

Tekst piosenki:

[Chorus]
I sold my soul to the devil for designer
They said, "Go to hell," but I told 'em I don’t wanna
If you know me well, then you know that I ain't goin'
’Cause I don't wanna, I don't wanna
I don't wanna die young
The city of angels where I have my fun
Don't wanna die young
When I'm gone, remember all I've done-one

[Verse]
We've had our fun-un
But now I’m done-one
’Cause you crazy (Yeah), I can't take it (No)
Just wanted to see you naked
Heard time like money, can’t waste it
What's the price of fame? 'Cause I can taste it
So I'm chasin’ (Yeah), and I'm facin'
A little Hennessy, it might be good for me

[Chorus]
I sold my soul to the devil for designer
They said, "Go to hell," but I told 'em I don't wanna
If you know me well, then you know that I ain't goin'
'Cause I don't wanna, I don't wanna
I don't wanna die young
The city of angels where I have my fun
Don't wanna die young
When I'm gone, remember all I've done-one
Dodaj interpretację do tego tekstu »

 

Historia edycji tekstu

Tłumaczenie:

Pokaż tłumaczenie
[Chór]
Sprzedałem duszę diabłu za projektanta
Powiedzieli „Idź do piekła”, ale powiedziałem im, że nie chcę
Jeśli dobrze mnie znasz, to wiesz, że nie idę
Bo nie chcę, nie chcę
Nie chcę umrzeć młodo
Miasto aniołów, w którym dobrze się bawię
Nie chcę umrzeć młodo
Kiedy odejdę, pamiętaj wszystko, co zrobiłem

[Werset]
Mieliśmy naszą zabawę
Ale teraz skończyłem
Bo jesteś szalony (Tak), nie mogę tego znieść (Nie)
Chciałem tylko zobaczyć cię nago
Słyszałem czas jak pieniądze, nie można go marnować
Jaka jest cena sławy? Bo mogę to posmakować
Więc chasin '(Yeah) i patrzę
Trochę Hennessy, może być dla mnie dobre

[Chór]
Sprzedałem duszę diabłu za projektanta
Powiedzieli „Idź do piekła”, ale powiedziałem im, że nie chcę
Jeśli dobrze mnie znasz, to wiesz, że nie idę
Bo nie chcę, nie chcę
Nie chcę umrzeć młodo
Miasto aniołów, w którym dobrze się bawię
Nie chcę umrzeć młodo
Kiedy odejdę, pamiętaj wszystko, co zrobiłem

 

Historia edycji tłumaczenia
Rok wydania:

2019

Edytuj metrykę
Płyty:

Dropped Outta College

Komentarze (0):

2 170 744 tekstów, 20 217 poszukiwanych i 501 oczekujących

Największy serwis z tekstami piosenek w Polsce. Każdy może znaleźć u nas teksty piosenek, teledyski oraz tłumaczenia swoich ulubionych utworów.
Zachęcamy wszystkich użytkowników do dodawania nowych tekstów, tłumaczeń i teledysków!


Reklama | Kontakt | FAQ Polityka prywatności
================================================ FILE: test/rsrc/lyrics/tekstowopl/piosenkabaileybiggerblackeyedsusan.txt ================================================ Bailey Bigger - Black Eyed Susan - tekst i tłumaczenie piosenki na Tekstowo.pl
2 170 745 tekstów, 20 217 poszukiwanych i 502 oczekujących

Bailey Bigger - Black Eyed Susan

Tekst dodał(a): Adelle Edytuj tekst
Tłumaczenie dodał(a): brak Dodaj tłumaczenie
Teledysk dodał(a): Adelle Edytuj teledysk

Tekst piosenki:

Black eyed Susan
Sun shines in your veins
If the clouds are moving
Never hear her complain
Yeah, black eyed Susan
Just waiting on a drop of rain

Black eyed Susan
Stands true and tall
If the storms are brewing
She ain't worried at all
Yeah, black eyed Susan
Just waiting on a drop to fall

Everyone calls her the sunflower
And no one knows her name
She's a little girl on the side of the road
Waiting on a drop of rain
Rain
Rain

Black eyed Susan
Ain't it just a shame?
'Cause now you're losing
All your petals in a vase
In a vase
She got picked and I never really knew her name
Everyone calls her the sunflower
And no one knows her name
She's a little girl on the side of the road
Waiting on a drop of rain
Rain
Rain
Yeah, she got picked and I never really knew her name
Dodaj interpretację do tego tekstu »

 

Historia edycji tekstu

Tłumaczenie:

Niestety nikt nie dodał jeszcze tłumaczenia tego utworu.

Dodaj tłumaczenie lub wyślij prośbę o tłumaczenie

 

Autor:

(brak)

Edytuj metrykę

Komentarze (0):

2 170 745 tekstów, 20 217 poszukiwanych i 502 oczekujących

Największy serwis z tekstami piosenek w Polsce. Każdy może znaleźć u nas teksty piosenek, teledyski oraz tłumaczenia swoich ulubionych utworów.
Zachęcamy wszystkich użytkowników do dodawania nowych tekstów, tłumaczeń i teledysków!


Reklama | Kontakt | FAQ Polityka prywatności
================================================ FILE: test/rsrc/lyrics/tekstowopl/piosenkabeethovenbeethovenpianosonata17tempestthe3rdmovement.txt ================================================ Beethoven - Beethoven Piano Sonata 17 Tempest The 3rd Movement - na Tekstowo.pl
2 170 744 tekstów, 20 217 poszukiwanych i 502 oczekujących

Beethoven - Beethoven Piano Sonata 17 Tempest The 3rd Movement

Utwór dodał(a): anmar09
Utwór instrumentalny Ten utwór ma słowa? Dodaj tekst
Teledysk dodał(a): anmar09 Edytuj teledysk
Ścieżka dźwiękowa:

Pokojówka

Komentarze (0):

2 170 744 tekstów, 20 217 poszukiwanych i 502 oczekujących

Największy serwis z tekstami piosenek w Polsce. Każdy może znaleźć u nas teksty piosenek, teledyski oraz tłumaczenia swoich ulubionych utworów.
Zachęcamy wszystkich użytkowników do dodawania nowych tekstów, tłumaczeń i teledysków!


Reklama | Kontakt | FAQ Polityka prywatności
================================================ FILE: test/rsrc/mbpseudo/official_release.json ================================================ { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "In Bloom", "primary": true, "sort-name": "In Bloom", "type": "Release name", "type-id": "df187855-059b-3514-9d5e-d240de0b4228" } ], "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "genres": [ { "count": 1, "disambiguation": "", "id": "eba7715e-ee26-4989-8d49-9db382955419", "name": "j-pop" }, { "count": 1, "disambiguation": "", "id": "455f264b-db00-4716-991d-fbd32dc24523", "name": "singer-songwriter" } ], "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "tags": [ { "count": 1, "name": "j-pop" }, { "count": 1, "name": "singer-songwriter" } ], "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "artist-relations": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": "2025", "direction": "backward", "end": "2025", "ended": true, "source-credit": "", "target-credit": "Lilas Ikuta", "type": "copyright", "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": "2025", "direction": "backward", "end": "2025", "ended": true, "source-credit": "", "target-credit": "Lilas Ikuta", "type": "phonographic copyright", "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc" } ], "asin": "B0DR8Y2YDC", "barcode": "199066336168", "country": "XW", "cover-art-archive": { "artwork": true, "back": false, "count": 1, "darkened": false, "front": true }, "date": "2025-01-10", "disambiguation": "", "genres": [], "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", "label-info": [ { "catalog-number": "Lilas-020", "label": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": null, "name": "2636621 Records DK", "primary": null, "sort-name": "2636621 Records DK", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Antipole", "primary": null, "sort-name": "Antipole", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Auto production", "primary": null, "sort-name": "Auto production", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Auto-Edición", "primary": null, "sort-name": "Auto-Edición", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Auto-Product", "primary": null, "sort-name": "Auto-Product", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Autoedición", "primary": null, "sort-name": "Autoedición", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Autoeditado", "primary": null, "sort-name": "Autoeditado", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Autoproduit", "primary": null, "sort-name": "Autoproduit", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Banana Skin Records", "primary": null, "sort-name": "Banana Skin Records", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Cannelle", "primary": null, "sort-name": "Cannelle", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Cece Natalie", "primary": null, "sort-name": "Cece Natalie", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Cherry X", "primary": null, "sort-name": "Cherry X", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Chung", "primary": null, "sort-name": "Chung", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Cody Johnson", "primary": null, "sort-name": "Cody Johnson", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Cowgirl Clue", "primary": null, "sort-name": "Cowgirl Clue", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "D.I.Y.", "primary": null, "sort-name": "D.I.Y.", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Damjan Mravunac Self-released)", "primary": null, "sort-name": "Damjan Mravunac Self-released)", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Demo", "primary": null, "sort-name": "Demo", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "DistroKid", "primary": null, "sort-name": "DistroKid", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Egzod", "primary": null, "sort-name": "Egzod", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Eigenverlag", "primary": null, "sort-name": "Eigenverlag", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Eigenvertrieb", "primary": null, "sort-name": "Eigenvertrieb", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "GRIND MODE", "primary": null, "sort-name": "GRIND MODE", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "INDIPENDANT", "primary": null, "sort-name": "INDIPENDANT", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Indepandant", "primary": null, "sort-name": "Indepandant", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Independant release", "primary": null, "sort-name": "Independant release", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Independent", "primary": null, "sort-name": "Independent", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Independente", "primary": null, "sort-name": "Independente", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Independiente", "primary": null, "sort-name": "Independiente", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Indie", "primary": null, "sort-name": "Indie", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Joost Klein", "primary": null, "sort-name": "Joost Klein", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Millington Records", "primary": null, "sort-name": "Millington Records", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "MoroseSound", "primary": null, "sort-name": "MoroseSound", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "N/A", "primary": null, "sort-name": "N/A", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "No Label", "primary": null, "sort-name": "No Label", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "None", "primary": null, "sort-name": "None", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "None Like Joshua", "primary": null, "sort-name": "None Like Joshua", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Not On A Lebel", "primary": null, "sort-name": "Not On A Lebel", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Not On Label", "primary": null, "sort-name": "Not On Label", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Offensively Average Productions", "primary": null, "sort-name": "Offensively Average Productions", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Ours", "primary": null, "sort-name": "Ours", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2019", "primary": null, "sort-name": "P2019", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2020", "primary": null, "sort-name": "P2020", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2021", "primary": null, "sort-name": "P2021", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2022", "primary": null, "sort-name": "P2022", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2023", "primary": null, "sort-name": "P2023", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2024", "primary": null, "sort-name": "P2024", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "P2025", "primary": null, "sort-name": "P2025", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Patriarchy", "primary": null, "sort-name": "Patriarchy", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Plini", "primary": null, "sort-name": "Plini", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Records DK", "primary": null, "sort-name": "Records DK", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self Digital", "primary": null, "sort-name": "Self Digital", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self Release", "primary": null, "sort-name": "Self Release", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self Released", "primary": null, "sort-name": "Self Released", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self-release", "primary": null, "sort-name": "Self-release", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self-released", "primary": null, "sort-name": "Self-released", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Self-released/independent", "primary": null, "sort-name": "Self-released/independent", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Sevdaliza", "primary": null, "sort-name": "Sevdaliza", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "TOMMY CASH", "primary": null, "sort-name": "TOMMY CASH", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Take Van", "primary": null, "sort-name": "Take Van", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Talwiinder", "primary": null, "sort-name": "Talwiinder", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Unsigned", "primary": null, "sort-name": "Unsigned", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "VGR", "primary": null, "sort-name": "VGR", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "Woo Da Savage", "primary": null, "sort-name": "Woo Da Savage", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "YANAA", "primary": null, "sort-name": "YANAA", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": "fi", "name": "[ei levymerkkiä]", "primary": true, "sort-name": "ei levymerkkiä", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "nl", "name": "[geen platenmaatschappij]", "primary": true, "sort-name": "[geen platenmaatschappij]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "et", "name": "[ilma plaadifirmata]", "primary": false, "sort-name": "[ilma plaadifirmata]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "es", "name": "[nada]", "primary": true, "sort-name": "[nada]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "en", "name": "[no label]", "primary": true, "sort-name": "[no label]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "[nolabel]", "primary": null, "sort-name": "[nolabel]", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "[none]", "primary": null, "sort-name": "[none]", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": "lt", "name": "[nėra leidybinės kompanijos]", "primary": false, "sort-name": "[nėra leidybinės kompanijos]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "lt", "name": "[nėra leidyklos]", "primary": false, "sort-name": "[nėra leidyklos]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "lt", "name": "[nėra įrašų kompanijos]", "primary": true, "sort-name": "[nėra įrašų kompanijos]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "et", "name": "[puudub]", "primary": false, "sort-name": "[puudub]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "ru", "name": "[самиздат]", "primary": false, "sort-name": "samizdat", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": "ja", "name": "[レーベルなし]", "primary": true, "sort-name": "[レーベルなし]", "type": "Label name", "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "annapantsu music", "primary": null, "sort-name": "annapantsu music", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "auto-release", "primary": null, "sort-name": "auto-release", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "autoprod.", "primary": null, "sort-name": "autoprod.", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "ayesha erotica", "primary": null, "sort-name": "ayesha erotica", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "blank", "primary": null, "sort-name": "blank", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "cupcakKe", "primary": null, "sort-name": "cupcakKe", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "d.silvestre", "primary": null, "sort-name": "d.silvestre", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "dj-Jo", "primary": null, "sort-name": "dj-Jo", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "independent release", "primary": null, "sort-name": "independent release", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "lor2mg", "primary": null, "sort-name": "lor2mg", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "nyamura", "primary": null, "sort-name": "nyamura", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "pls dnt stp", "primary": null, "sort-name": "pls dnt stp", "type": null, "type-id": null }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "self", "primary": null, "sort-name": "self", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "self issued", "primary": null, "sort-name": "self issued", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "self-issued", "primary": null, "sort-name": "self-issued", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "white label", "primary": null, "sort-name": "white label", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "но лабел", "primary": null, "sort-name": "но лабел", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, "end": null, "ended": false, "locale": null, "name": "独立发行", "primary": null, "sort-name": "独立发行", "type": "Search hint", "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" } ], "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", "genres": [], "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", "label-code": null, "name": "[no label]", "sort-name": "[no label]", "tags": [ { "count": 12, "name": "special purpose" }, { "count": 18, "name": "special purpose label" } ], "type": "Production", "type-id": "a2426aab-2dd4-339c-b47d-b4923a241678" } } ], "media": [ { "format": "Digital Media", "format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", "id": "43f08d54-a896-3561-be75-b881cbc832d5", "position": 1, "title": "", "track-count": 1, "track-offset": 0, "tracks": [ { "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", "length": 179239, "number": "1", "position": 1, "recording": { "aliases": [], "artist-credit": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "artist-relations": [ { "artist": { "country": "JP", "disambiguation": "Japanese composer/arranger/guitarist, agehasprings", "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", "name": "KOHD", "sort-name": "KOHD", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "arranger", "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": "2025", "direction": "backward", "end": "2025", "ended": true, "source-credit": "", "target-credit": "Lilas Ikuta", "type": "phonographic copyright", "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30" }, { "artist": { "country": "JP", "disambiguation": "", "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", "name": "山本秀哉", "sort-name": "Yamamoto, Shuya", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa" } ], "disambiguation": "", "first-release-date": "2025-01-10", "genres": [], "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", "isrcs": [ "JPP302400868" ], "length": 179546, "tags": [], "title": "百花繚乱", "url-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "free streaming", "type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c", "url": { "id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b", "resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "92777657-504c-4acb-bd33-51a201bd57e1", "url": { "id": "64879627-6eca-4755-98b5-b2234a8dbc61", "resource": "https://music.apple.com/jp/song/1857886416" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "streaming", "type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122", "url": { "id": "64879627-6eca-4755-98b5-b2234a8dbc61", "resource": "https://music.apple.com/jp/song/1857886416" } } ], "video": false, "work-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "work": { "artist-relations": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c" } ], "attributes": [], "disambiguation": "", "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", "iswcs": [], "language": "jpn", "languages": [ "jpn" ], "title": "百花繚乱", "type": "Song", "type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6", "url-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyrics", "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", "url": { "id": "dfac3640-6b23-4991-a59c-7cb80e8eb950", "resource": "https://utaten.com/lyric/tt24121002/" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyrics", "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", "url": { "id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415", "resource": "https://www.uta-net.com/song/366579/" } } ] } } ] }, "title": "百花繚乱" } ] } ], "packaging": "None", "packaging-id": "119eba76-b343-3e02-a292-f0f00644bb9b", "quality": "normal", "release-events": [ { "area": { "disambiguation": "", "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", "iso-3166-1-codes": [ "XW" ], "name": "[Worldwide]", "sort-name": "[Worldwide]", "type": null, "type-id": null }, "date": "2025-01-10" } ], "release-group": { "aliases": [], "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "disambiguation": "", "first-release-date": "2025-01-10", "genres": [], "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", "primary-type": "Single", "primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9", "secondary-type-ids": [], "secondary-types": [], "tags": [], "title": "百花繚乱" }, "release-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "release": { "artist-credit": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": null, "type-id": null }, "joinphrase": "", "name": "Lilas Ikuta" } ], "barcode": null, "disambiguation": "", "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", "media": [], "packaging": null, "packaging-id": null, "quality": "normal", "release-group": null, "status": null, "status-id": null, "text-representation": { "language": "eng", "script": "Latn" }, "title": "In Bloom" }, "source-credit": "", "target-credit": "", "type": "transl-tracklisting", "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" } ], "status": "Official", "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", "tags": [], "text-representation": { "language": "jpn", "script": "Jpan" }, "title": "百花繚乱", "url-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "amazon asin", "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", "url": { "id": "b50c7fb8-2327-4a05-b989-f2211a41afee", "resource": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "free streaming", "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", "url": { "id": "5106a7b0-1443-4803-91a2-28cac2cfb5e0", "resource": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "free streaming", "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", "url": { "id": "d481d94b-a7bf-4e82-8da0-1757fedcda62", "resource": "https://www.deezer.com/album/687686261" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "6156d2e4-d107-43f9-8f44-52f04d39c78e", "resource": "https://mora.jp/package/43000011/199066336168/" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "a4eabb88-1746-4aa2-ab09-c28cfbe65efb", "resource": "https://mora.jp/package/43000011/199066336168_HD/" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "ab8440f0-3b13-4436-b3ad-f4695c9d8875", "resource": "https://mora.jp/package/43000011/199066336168_LL/" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", "resource": "https://music.apple.com/jp/album/1786972161" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "c6faaa80-38fb-46a4-aa2b-78cddc5cbe70", "resource": "https://ototoy.jp/_/default/p/2501951" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "0e7e8bc5-0779-492d-a9db-9ab58f96d23b", "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "c0cf8fe0-3413-4544-a026-37d346a59a77", "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "streaming", "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", "url": { "id": "e4ce55a9-a5e1-4842-b42d-11be6a31fdab", "resource": "https://music.amazon.co.jp/albums/B0DR8Y2YDC" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "streaming", "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", "url": { "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", "resource": "https://music.apple.com/jp/album/1786972161" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "vgmdb", "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", "url": { "id": "1885772a-4004-4d45-9512-d0c8822506c9", "resource": "https://vgmdb.net/album/145936" } } ] } ================================================ FILE: test/rsrc/mbpseudo/pseudo_release.json ================================================ { "aliases": [], "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "genres": [ { "count": 1, "disambiguation": "", "id": "eba7715e-ee26-4989-8d49-9db382955419", "name": "j-pop" }, { "count": 1, "disambiguation": "", "id": "455f264b-db00-4716-991d-fbd32dc24523", "name": "singer-songwriter" } ], "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "tags": [ { "count": 1, "name": "j-pop" }, { "count": 1, "name": "singer-songwriter" } ], "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "Lilas Ikuta" } ], "asin": null, "barcode": null, "cover-art-archive": { "artwork": false, "back": false, "count": 0, "darkened": false, "front": false }, "disambiguation": "", "genres": [], "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", "label-info": [], "media": [ { "format": "Digital Media", "format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", "id": "606faab7-60fa-3a8b-a40f-2c66150cce81", "position": 1, "title": "", "track-count": 1, "track-offset": 0, "tracks": [ { "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "Lilas Ikuta" } ], "id": "2018b012-a184-49a2-a464-fb4628a89588", "length": 179239, "number": "1", "position": 1, "recording": { "aliases": [], "artist-credit": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "artist-relations": [ { "artist": { "country": "JP", "disambiguation": "Japanese composer/arranger/guitarist, agehasprings", "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", "name": "KOHD", "sort-name": "KOHD", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "arranger", "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": "2025", "direction": "backward", "end": "2025", "ended": true, "source-credit": "", "target-credit": "Lilas Ikuta", "type": "phonographic copyright", "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30" }, { "artist": { "country": "JP", "disambiguation": "", "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", "name": "山本秀哉", "sort-name": "Yamamoto, Shuya", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "producer", "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "vocal", "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa" } ], "disambiguation": "", "first-release-date": "2025-01-10", "genres": [], "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", "isrcs": [ "JPP302400868" ], "length": 179546, "tags": [], "title": "百花繚乱", "url-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "free streaming", "type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c", "url": { "id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b", "resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "purchase for download", "type-id": "92777657-504c-4acb-bd33-51a201bd57e1", "url": { "id": "64879627-6eca-4755-98b5-b2234a8dbc61", "resource": "https://music.apple.com/jp/song/1857886416" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "streaming", "type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122", "url": { "id": "64879627-6eca-4755-98b5-b2234a8dbc61", "resource": "https://music.apple.com/jp/song/1857886416" } } ], "video": false, "work-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "forward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "performance", "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", "work": { "artist-relations": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "composer", "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f" }, { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyricist", "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c" } ], "attributes": [], "disambiguation": "", "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", "iswcs": [], "language": "jpn", "languages": [ "jpn" ], "title": "百花繚乱", "type": "Song", "type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6", "url-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyrics", "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", "url": { "id": "dfac3640-6b23-4991-a59c-7cb80e8eb950", "resource": "https://utaten.com/lyric/tt24121002/" } }, { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "source-credit": "", "target-credit": "", "type": "lyrics", "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", "url": { "id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415", "resource": "https://www.uta-net.com/song/366579/" } } ] } } ] }, "title": "In Bloom" } ] } ], "packaging": null, "packaging-id": null, "quality": "normal", "release-group": { "aliases": [], "artist-credit": [ { "artist": { "aliases": [ { "begin": null, "end": null, "ended": false, "locale": "en", "name": "Lilas Ikuta", "primary": true, "sort-name": "Ikuta, Lilas", "type": "Artist name", "type-id": "894afba6-2816-3c24-8072-eadb66bd04bc" } ], "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": "Person", "type-id": "b6e035f4-3ce9-331c-97df-83397230b0df" }, "joinphrase": "", "name": "幾田りら" } ], "disambiguation": "", "first-release-date": "2025-01-10", "genres": [], "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", "primary-type": "Single", "primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9", "secondary-type-ids": [], "secondary-types": [], "tags": [], "title": "百花繚乱" }, "release-relations": [ { "attribute-ids": {}, "attribute-values": {}, "attributes": [], "begin": null, "direction": "backward", "end": null, "ended": false, "release": { "artist-credit": [ { "artist": { "country": "JP", "disambiguation": "", "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", "sort-name": "Ikuta, Lilas", "type": null, "type-id": null }, "joinphrase": "", "name": "幾田りら" } ], "barcode": "199066336168", "country": "XW", "date": "2025-01-10", "disambiguation": "", "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", "media": [], "packaging": null, "packaging-id": null, "quality": "normal", "release-events": [ { "area": { "disambiguation": "", "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", "iso-3166-1-codes": [ "XW" ], "name": "[Worldwide]", "sort-name": "[Worldwide]", "type": null, "type-id": null }, "date": "2025-01-10" } ], "release-group": null, "status": null, "status-id": null, "text-representation": { "language": "jpn", "script": "Jpan" }, "title": "百花繚乱" }, "source-credit": "", "target-credit": "", "type": "transl-tracklisting", "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" } ], "status": "Pseudo-Release", "status-id": "41121bb9-3413-3818-8a9a-9742318349aa", "tags": [], "text-representation": { "language": "eng", "script": "Latn" }, "title": "In Bloom" } ================================================ FILE: test/rsrc/playlist.m3u ================================================ #EXTM3U /This/is/a/path/to_a_file.mp3 /This/is/another/path/to_a_file.mp3 ================================================ FILE: test/rsrc/playlist.m3u8 ================================================ #EXTM3U /This/is/å/path/to_a_file.mp3 /This/is/another/path/tö_a_file.mp3 ================================================ FILE: test/rsrc/playlist_non_ext.m3u ================================================ /This/is/a/path/to_a_file.mp3 /This/is/another/path/to_a_file.mp3 ================================================ FILE: test/rsrc/playlist_windows.m3u8 ================================================ #EXTM3U x:\This\is\å\path\to_a_file.mp3 x:\This\is\another\path\tö_a_file.mp3 ================================================ FILE: test/rsrc/spotify/album_info.json ================================================ { "album_type": "compilation", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/0LyfQWJT6nXafLPZqxe9Of" }, "href": "https://api.spotify.com/v1/artists/0LyfQWJT6nXafLPZqxe9Of", "id": "0LyfQWJT6nXafLPZqxe9Of", "name": "Various Artists", "type": "artist", "uri": "spotify:artist:0LyfQWJT6nXafLPZqxe9Of" } ], "available_markets": [], "copyrights": [ { "text": "2013 Back Lot Music", "type": "C" }, { "text": "2013 Back Lot Music", "type": "P" } ], "external_ids": { "upc": "857970002363" }, "external_urls": { "spotify": "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "genres": [], "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id": "5l3zEmMrOhOzG8d8s83GOL", "images": [ { "height": 640, "url": "https://i.scdn.co/image/ab67616d0000b27399140a62d43aec760f6172a2", "width": 640 }, { "height": 300, "url": "https://i.scdn.co/image/ab67616d00001e0299140a62d43aec760f6172a2", "width": 300 }, { "height": 64, "url": "https://i.scdn.co/image/ab67616d0000485199140a62d43aec760f6172a2", "width": 64 } ], "label": "Back Lot Music", "name": "Despicable Me 2 (Original Motion Picture Soundtrack)", "popularity": 0, "release_date": "2013-06-18", "release_date_precision": "day", "total_tracks": 24, "tracks": { "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL/tracks?offset=0&limit=50", "items": [ { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/5nLYd9ST4Cnwy6NHaCxbj8" }, "href": "https://api.spotify.com/v1/artists/5nLYd9ST4Cnwy6NHaCxbj8", "id": "5nLYd9ST4Cnwy6NHaCxbj8", "name": "CeeLo Green", "type": "artist", "uri": "spotify:artist:5nLYd9ST4Cnwy6NHaCxbj8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 221805, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3EiEbQAR44icEkz3rsMI0N" }, "href": "https://api.spotify.com/v1/tracks/3EiEbQAR44icEkz3rsMI0N", "id": "3EiEbQAR44icEkz3rsMI0N", "is_local": false, "name": "Scream", "preview_url": null, "track_number": 1, "type": "track", "uri": "spotify:track:3EiEbQAR44icEkz3rsMI0N" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 39065, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1G4Z91vvEGTYd2ZgOD0MuN" }, "href": "https://api.spotify.com/v1/tracks/1G4Z91vvEGTYd2ZgOD0MuN", "id": "1G4Z91vvEGTYd2ZgOD0MuN", "is_local": false, "name": "Another Irish Drinking Song", "preview_url": null, "track_number": 2, "type": "track", "uri": "spotify:track:1G4Z91vvEGTYd2ZgOD0MuN" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 176078, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7DKqhn3Aa0NT9N9GAcagda" }, "href": "https://api.spotify.com/v1/tracks/7DKqhn3Aa0NT9N9GAcagda", "id": "7DKqhn3Aa0NT9N9GAcagda", "is_local": false, "name": "Just a Cloud Away", "preview_url": null, "track_number": 3, "type": "track", "uri": "spotify:track:7DKqhn3Aa0NT9N9GAcagda" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 233305, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href": "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id": "6NPVjNh8Jhru9xOmyQigds", "is_local": false, "name": "Happy", "preview_url": null, "track_number": 4, "type": "track", "uri": "spotify:track:6NPVjNh8Jhru9xOmyQigds" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 98211, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/5HSqCeDCn2EEGR5ORwaHA0" }, "href": "https://api.spotify.com/v1/tracks/5HSqCeDCn2EEGR5ORwaHA0", "id": "5HSqCeDCn2EEGR5ORwaHA0", "is_local": false, "name": "I Swear", "preview_url": null, "track_number": 5, "type": "track", "uri": "spotify:track:5HSqCeDCn2EEGR5ORwaHA0" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 175291, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2Ls4QknWvBoGSeAlNKw0Xj" }, "href": "https://api.spotify.com/v1/tracks/2Ls4QknWvBoGSeAlNKw0Xj", "id": "2Ls4QknWvBoGSeAlNKw0Xj", "is_local": false, "name": "Y.M.C.A.", "preview_url": null, "track_number": 6, "type": "track", "uri": "spotify:track:2Ls4QknWvBoGSeAlNKw0Xj" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 206105, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1XkUmKLbm1tzVtrkdj2Ou8" }, "href": "https://api.spotify.com/v1/tracks/1XkUmKLbm1tzVtrkdj2Ou8", "id": "1XkUmKLbm1tzVtrkdj2Ou8", "is_local": false, "name": "Fun, Fun, Fun", "preview_url": null, "track_number": 7, "type": "track", "uri": "spotify:track:1XkUmKLbm1tzVtrkdj2Ou8" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 254705, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/42lHGtAZd6xVLC789afLWt" }, "href": "https://api.spotify.com/v1/tracks/42lHGtAZd6xVLC789afLWt", "id": "42lHGtAZd6xVLC789afLWt", "is_local": false, "name": "Despicable Me", "preview_url": null, "track_number": 8, "type": "track", "uri": "spotify:track:42lHGtAZd6xVLC789afLWt" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 126825, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7uAC260NViRKyYW4st4vri" }, "href": "https://api.spotify.com/v1/tracks/7uAC260NViRKyYW4st4vri", "id": "7uAC260NViRKyYW4st4vri", "is_local": false, "name": "PX-41 Labs", "preview_url": null, "track_number": 9, "type": "track", "uri": "spotify:track:7uAC260NViRKyYW4st4vri" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87118, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6YLmc6yT7OGiNwbShHuEN2" }, "href": "https://api.spotify.com/v1/tracks/6YLmc6yT7OGiNwbShHuEN2", "id": "6YLmc6yT7OGiNwbShHuEN2", "is_local": false, "name": "The Fairy Party", "preview_url": null, "track_number": 10, "type": "track", "uri": "spotify:track:6YLmc6yT7OGiNwbShHuEN2" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 339478, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/5lwsXhSXKFoxoGOFLZdQX6" }, "href": "https://api.spotify.com/v1/tracks/5lwsXhSXKFoxoGOFLZdQX6", "id": "5lwsXhSXKFoxoGOFLZdQX6", "is_local": false, "name": "Lucy And The AVL", "preview_url": null, "track_number": 11, "type": "track", "uri": "spotify:track:5lwsXhSXKFoxoGOFLZdQX6" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87478, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2FlWtPuBMGo0a0X7LGETyk" }, "href": "https://api.spotify.com/v1/tracks/2FlWtPuBMGo0a0X7LGETyk", "id": "2FlWtPuBMGo0a0X7LGETyk", "is_local": false, "name": "Goodbye Nefario", "preview_url": null, "track_number": 12, "type": "track", "uri": "spotify:track:2FlWtPuBMGo0a0X7LGETyk" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 86998, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3YnhGNADeUaoBTjB1uGUjh" }, "href": "https://api.spotify.com/v1/tracks/3YnhGNADeUaoBTjB1uGUjh", "id": "3YnhGNADeUaoBTjB1uGUjh", "is_local": false, "name": "Time for Bed", "preview_url": null, "track_number": 13, "type": "track", "uri": "spotify:track:3YnhGNADeUaoBTjB1uGUjh" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 180265, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6npUKThV4XI20VLW5ryr5O" }, "href": "https://api.spotify.com/v1/tracks/6npUKThV4XI20VLW5ryr5O", "id": "6npUKThV4XI20VLW5ryr5O", "is_local": false, "name": "Break-In", "preview_url": null, "track_number": 14, "type": "track", "uri": "spotify:track:6npUKThV4XI20VLW5ryr5O" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 95011, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1qyFlqVfbgyiM7tQ2Jy9vC" }, "href": "https://api.spotify.com/v1/tracks/1qyFlqVfbgyiM7tQ2Jy9vC", "id": "1qyFlqVfbgyiM7tQ2Jy9vC", "is_local": false, "name": "Stalking Floyd Eaglesan", "preview_url": null, "track_number": 15, "type": "track", "uri": "spotify:track:1qyFlqVfbgyiM7tQ2Jy9vC" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 189771, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/4DRQctGiqjJkbFa7iTK4pb" }, "href": "https://api.spotify.com/v1/tracks/4DRQctGiqjJkbFa7iTK4pb", "id": "4DRQctGiqjJkbFa7iTK4pb", "is_local": false, "name": "Moving to Australia", "preview_url": null, "track_number": 16, "type": "track", "uri": "spotify:track:4DRQctGiqjJkbFa7iTK4pb" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 85878, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1TSjM9GY2oN6RO6aYGN25n" }, "href": "https://api.spotify.com/v1/tracks/1TSjM9GY2oN6RO6aYGN25n", "id": "1TSjM9GY2oN6RO6aYGN25n", "is_local": false, "name": "Going to Save the World", "preview_url": null, "track_number": 17, "type": "track", "uri": "spotify:track:1TSjM9GY2oN6RO6aYGN25n" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87158, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3AEMuoglM1myQ8ouIyh8LG" }, "href": "https://api.spotify.com/v1/tracks/3AEMuoglM1myQ8ouIyh8LG", "id": "3AEMuoglM1myQ8ouIyh8LG", "is_local": false, "name": "El Macho", "preview_url": null, "track_number": 18, "type": "track", "uri": "spotify:track:3AEMuoglM1myQ8ouIyh8LG" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 47438, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2d7fEVYdZnjlya3MPEma21" }, "href": "https://api.spotify.com/v1/tracks/2d7fEVYdZnjlya3MPEma21", "id": "2d7fEVYdZnjlya3MPEma21", "is_local": false, "name": "Jillian", "preview_url": null, "track_number": 19, "type": "track", "uri": "spotify:track:2d7fEVYdZnjlya3MPEma21" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 89398, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7h8WnOo4Fh6NvfTUnR7nOa" }, "href": "https://api.spotify.com/v1/tracks/7h8WnOo4Fh6NvfTUnR7nOa", "id": "7h8WnOo4Fh6NvfTUnR7nOa", "is_local": false, "name": "Take Her Home", "preview_url": null, "track_number": 20, "type": "track", "uri": "spotify:track:7h8WnOo4Fh6NvfTUnR7nOa" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 212691, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/25A9ZlegjJ0z2fI1PgTqy2" }, "href": "https://api.spotify.com/v1/tracks/25A9ZlegjJ0z2fI1PgTqy2", "id": "25A9ZlegjJ0z2fI1PgTqy2", "is_local": false, "name": "El Macho's Lair", "preview_url": null, "track_number": 21, "type": "track", "uri": "spotify:track:25A9ZlegjJ0z2fI1PgTqy2" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 117745, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/48GwOCuPhWKDktq3efmfRg" }, "href": "https://api.spotify.com/v1/tracks/48GwOCuPhWKDktq3efmfRg", "id": "48GwOCuPhWKDktq3efmfRg", "is_local": false, "name": "Home Invasion", "preview_url": null, "track_number": 22, "type": "track", "uri": "spotify:track:48GwOCuPhWKDktq3efmfRg" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 443251, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6dZkl2egcKVm8rO9W7pPWa" }, "href": "https://api.spotify.com/v1/tracks/6dZkl2egcKVm8rO9W7pPWa", "id": "6dZkl2egcKVm8rO9W7pPWa", "is_local": false, "name": "The Big Battle", "preview_url": null, "track_number": 23, "type": "track", "uri": "spotify:track:6dZkl2egcKVm8rO9W7pPWa" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 13886, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2L0OyiAepqAbKvUZfWovOJ" }, "href": "https://api.spotify.com/v1/tracks/2L0OyiAepqAbKvUZfWovOJ", "id": "2L0OyiAepqAbKvUZfWovOJ", "is_local": false, "name": "Ba Do Bleep", "preview_url": null, "track_number": 24, "type": "track", "uri": "spotify:track:2L0OyiAepqAbKvUZfWovOJ" } ], "limit": 50, "next": null, "offset": 0, "previous": null, "total": 24 }, "type": "album", "uri": "spotify:album:5l3zEmMrOhOzG8d8s83GOL" } ================================================ FILE: test/rsrc/spotify/japanese_track_request.json ================================================ { "tracks":{ "href":"https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", "items":[ { "album":{ "album_type":"compilation", "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "external_urls":{ "spotify":"https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "href":"https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id":"5l3zEmMrOhOzG8d8s83GOL", "images":[ { "height":640, "width":640, "url":"https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8" }, { "height":300, "width":300, "url":"https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb" }, { "height":64, "width":64, "url":"https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8" } ], "name":"盗作", "type":"album", "uri":"spotify:album:5l3zEmMrOhOzG8d8s83GOL" }, "artists":[ { "external_urls":{ "spotify":"https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href":"https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id":"2RdwBSPQiwcmiDo9kixcl8", "name":"ヨルシカ", "type":"artist", "uri":"spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "disc_number":1, "duration_ms":233305, "explicit":false, "external_ids":{ "isrc":"USQ4E1300686" }, "external_urls":{ "spotify":"https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href":"https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id":"6NPVjNh8Jhru9xOmyQigds", "name":"思想犯", "popularity":89, "preview_url":"https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", "track_number":4, "type":"track", "uri":"spotify:track:6NPVjNh8Jhru9xOmyQigds" } ], "limit":20, "next":null, "offset":0, "previous":null, "total":1 } } ================================================ FILE: test/rsrc/spotify/missing_request.json ================================================ { "tracks" : { "href" : "https://api.spotify.com/v1/search?query=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&offset=0&limit=20&type=track", "items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0 } } ================================================ FILE: test/rsrc/spotify/multiartist_album.json ================================================ { "album_type": "single", "total_tracks": 1, "available_markets": [ "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" ], "external_urls": { "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" }, "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", "id": "0yhKyyjyKXWUieJ4w1IAEa", "images": [ { "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", "height": 640, "width": 640 }, { "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", "height": 300, "width": 300 }, { "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", "height": 64, "width": 64 } ], "name": "Akiba Night", "release_date": "2017-12-22", "release_date_precision": "day", "type": "album", "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", "id": "6m8MRXIVKb6wQaPlBIDMr1", "name": "Project Skylate", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, { "external_urls": { "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", "id": "4kkAIoQmNT5xEoNH5BuQLe", "name": "Sugar Shrill", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } ], "tracks": { "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa/tracks?offset=0&limit=50", "limit": 50, "next": null, "offset": 0, "previous": null, "total": 1, "items": [ { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", "id": "12345", "name": "Foo", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, { "external_urls": { "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", "id": "67890", "name": "Bar", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } ], "available_markets": [ "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" ], "disc_number": 1, "duration_ms": 225268, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" }, "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", "id": "6sjZfVJworBX6TqyjkxIJ1", "name": "Akiba Nights", "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", "track_number": 1, "type": "track", "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1", "is_local": false } ] }, "copyrights": [ { "text": "2017 Sugar Shrill", "type": "C" }, { "text": "2017 Project Skylate", "type": "P" } ], "external_ids": { "upc": "5057728789361" }, "genres": [], "label": "Project Skylate", "popularity": 21 } ================================================ FILE: test/rsrc/spotify/multiartist_track.json ================================================ { "album": { "album_type": "single", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", "id": "6m8MRXIVKb6wQaPlBIDMr1", "name": "Project Skylate", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, { "external_urls": { "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", "id": "4kkAIoQmNT5xEoNH5BuQLe", "name": "Sugar Shrill", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } ], "available_markets": [ "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" ], "external_urls": { "spotify": "https://open.spotify.com/album/0yhKyyjyKXWUieJ4w1IAEa" }, "href": "https://api.spotify.com/v1/albums/0yhKyyjyKXWUieJ4w1IAEa", "id": "0yhKyyjyKXWUieJ4w1IAEa", "images": [ { "url": "https://i.scdn.co/image/ab67616d0000b2739a26f5e04909c87cead97c77", "width": 640, "height": 640 }, { "url": "https://i.scdn.co/image/ab67616d00001e029a26f5e04909c87cead97c77", "width": 300, "height": 300 }, { "url": "https://i.scdn.co/image/ab67616d000048519a26f5e04909c87cead97c77", "width": 64, "height": 64 } ], "name": "Akiba Night", "release_date": "2017-12-22", "release_date_precision": "day", "total_tracks": 1, "type": "album", "uri": "spotify:album:0yhKyyjyKXWUieJ4w1IAEa" }, "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/6m8MRXIVKb6wQaPlBIDMr1" }, "href": "https://api.spotify.com/v1/artists/6m8MRXIVKb6wQaPlBIDMr1", "id": "12345", "name": "Foo", "type": "artist", "uri": "spotify:artist:6m8MRXIVKb6wQaPlBIDMr1" }, { "external_urls": { "spotify": "https://open.spotify.com/artist/4kkAIoQmNT5xEoNH5BuQLe" }, "href": "https://api.spotify.com/v1/artists/4kkAIoQmNT5xEoNH5BuQLe", "id": "67890", "name": "Bar", "type": "artist", "uri": "spotify:artist:4kkAIoQmNT5xEoNH5BuQLe" } ], "available_markets": [ "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "DE", "EC", "EE", "SV", "FI", "FR", "GR", "GT", "HN", "HK", "HU", "IS", "IE", "IT", "LV", "LT", "LU", "MY", "MT", "MX", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "SK", "ES", "SE", "CH", "TW", "TR", "UY", "US", "GB", "AD", "LI", "MC", "ID", "JP", "TH", "VN", "RO", "IL", "ZA", "SA", "AE", "BH", "QA", "OM", "KW", "EG", "MA", "DZ", "TN", "LB", "JO", "PS", "IN", "BY", "KZ", "MD", "UA", "AL", "BA", "HR", "ME", "MK", "RS", "SI", "KR", "BD", "PK", "LK", "GH", "KE", "NG", "TZ", "UG", "AG", "AM", "BS", "BB", "BZ", "BT", "BW", "BF", "CV", "CW", "DM", "FJ", "GM", "GE", "GD", "GW", "GY", "HT", "JM", "KI", "LS", "LR", "MW", "MV", "ML", "MH", "FM", "NA", "NR", "NE", "PW", "PG", "PR", "WS", "SM", "ST", "SN", "SC", "SL", "SB", "KN", "LC", "VC", "SR", "TL", "TO", "TT", "TV", "VU", "AZ", "BN", "BI", "KH", "CM", "TD", "KM", "GQ", "SZ", "GA", "GN", "KG", "LA", "MO", "MR", "MN", "NP", "RW", "TG", "UZ", "ZW", "BJ", "MG", "MU", "MZ", "AO", "CI", "DJ", "ZM", "CD", "CG", "IQ", "LY", "TJ", "VE", "ET", "XK" ], "disc_number": 1, "duration_ms": 225268, "explicit": false, "external_ids": { "isrc": "GB-SMU-45-66095" }, "external_urls": { "spotify": "https://open.spotify.com/track/6sjZfVJworBX6TqyjkxIJ1" }, "href": "https://api.spotify.com/v1/tracks/6sjZfVJworBX6TqyjkxIJ1", "id": "6sjZfVJworBX6TqyjkxIJ1", "is_local": false, "name": "Akiba Nights", "popularity": 29, "preview_url": "https://p.scdn.co/mp3-preview/a1c6c0c71f42caff0b19d988849602fefbf7754a?cid=4e414367a1d14c75a5c5129a627fcab8", "track_number": 1, "type": "track", "uri": "spotify:track:6sjZfVJworBX6TqyjkxIJ1" } ================================================ FILE: test/rsrc/spotify/track_info.json ================================================ { "album": { "album_type": "compilation", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/0LyfQWJT6nXafLPZqxe9Of" }, "href": "https://api.spotify.com/v1/artists/0LyfQWJT6nXafLPZqxe9Of", "id": "0LyfQWJT6nXafLPZqxe9Of", "name": "Various Artists", "type": "artist", "uri": "spotify:artist:0LyfQWJT6nXafLPZqxe9Of" } ], "available_markets": [], "external_urls": { "spotify": "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id": "5l3zEmMrOhOzG8d8s83GOL", "images": [ { "height": 640, "url": "https://i.scdn.co/image/ab67616d0000b27399140a62d43aec760f6172a2", "width": 640 }, { "height": 300, "url": "https://i.scdn.co/image/ab67616d00001e0299140a62d43aec760f6172a2", "width": 300 }, { "height": 64, "url": "https://i.scdn.co/image/ab67616d0000485199140a62d43aec760f6172a2", "width": 64 } ], "name": "Despicable Me 2 (Original Motion Picture Soundtrack)", "release_date": "2013-06-18", "release_date_precision": "day", "total_tracks": 24, "type": "album", "uri": "spotify:album:5l3zEmMrOhOzG8d8s83GOL" }, "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 233305, "explicit": false, "external_ids": { "isrc": "USQ4E1300686" }, "external_urls": { "spotify": "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href": "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id": "6NPVjNh8Jhru9xOmyQigds", "is_local": false, "name": "Happy", "popularity": 1, "preview_url": null, "track_number": 4, "type": "track", "uri": "spotify:track:6NPVjNh8Jhru9xOmyQigds" } ================================================ FILE: test/rsrc/spotify/track_request.json ================================================ { "tracks":{ "href":"https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", "items":[ { "album":{ "album_type":"compilation", "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "external_urls":{ "spotify":"https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "href":"https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id":"5l3zEmMrOhOzG8d8s83GOL", "images":[ { "height":640, "width":640, "url":"https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8" }, { "height":300, "width":300, "url":"https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb" }, { "height":64, "width":64, "url":"https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8" } ], "name":"Despicable Me 2 (Original Motion Picture Soundtrack)", "type":"album", "uri":"spotify:album:5l3zEmMrOhOzG8d8s83GOL" }, "artists":[ { "external_urls":{ "spotify":"https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href":"https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id":"2RdwBSPQiwcmiDo9kixcl8", "name":"Pharrell Williams", "type":"artist", "uri":"spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "disc_number":1, "duration_ms":233305, "explicit":false, "external_ids":{ "isrc":"USQ4E1300686" }, "external_urls":{ "spotify":"https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href":"https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id":"6NPVjNh8Jhru9xOmyQigds", "name":"Happy", "popularity":89, "preview_url":"https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", "track_number":4, "type":"track", "uri":"spotify:track:6NPVjNh8Jhru9xOmyQigds" } ], "limit":20, "next":null, "offset":0, "previous":null, "total":1 } } ================================================ FILE: test/rsrc/test_completion.sh ================================================ # Function stub compopt() { return 0; } initcli() { COMP_WORDS=( "beet" "$@" ) let COMP_CWORD=${#COMP_WORDS[@]}-1 COMP_LINE="${COMP_WORDS[@]}" let COMP_POINT=${#COMP_LINE} _beet } completes() { for word in "$@"; do [[ " ${COMPREPLY[@]} " == *[[:space:]]$word[[:space:]]* ]] || return 1 done } COMMANDS='fields import list update remove stats version modify move write help' HELP_OPTS='-h --help' test_commands() { initcli '' && completes $COMMANDS && initcli -v '' && completes $COMMANDS && initcli -l help '' && completes $COMMANDS && initcli -d list '' && completes $COMMANDS && initcli -h '' && completes $COMMANDS && true } test_command_aliases() { initcli ls && completes list && initcli l && ! completes ls && initcli im && completes import && true } test_global_opts() { initcli - && completes \ -l --library \ -d --directory \ -h --help \ -c --config \ -v --verbose && true } test_global_file_opts() { # FIXME somehow file completion only works when the completion # function is called by the shell completion utilities. So we can't # test it here initcli --library '' && completes $(compgen -d) && initcli -l '' && completes $(compgen -d) && initcli --config '' && completes $(compgen -d) && initcli -c '' && completes $(compgen -d) && true } test_global_dir_opts() { initcli --directory '' && completes $(compgen -d) && initcli -d '' && completes $(compgen -d) && true } test_fields_command() { initcli fields - && completes -h --help && initcli fields '' && completes $(compgen -d) && true } test_import_files() { initcli import '' && completes $(compgen -d) && initcli import --copy -P '' && completes $(compgen -d) && initcli import --log '' && completes $(compgen -d) && true } test_import_options() { initcli imp - completes \ -h --help \ -c --copy -C --nocopy \ -w --write -W --nowrite \ -a --autotag -A --noautotag \ -p --resume -P --noresume \ -l --log --flat } test_list_options() { initcli list - completes \ -h --help \ -a --album \ -p --path } test_list_query() { initcli list 'x' && [[ -z "${COMPREPLY[@]}" ]] && initcli list 'art' && completes \ 'artist:' \ 'artpath:' && initcli list 'artits:x' && [[ -z "${COMPREPLY[@]}" ]] && true } test_help_command() { initcli help '' && completes $COMMANDS && true } test_plugin_command() { initcli te && completes test && initcli test - && completes -o --option && true } run_tests() { local tests=$(set | \ grep --extended-regexp --only-matching '^test_[a-zA-Z_]* \(\) $' |\ grep --extended-regexp --only-matching '[a-zA-Z_]*' ) local fail=0 if [[ -n $@ ]]; then tests="$@" fi for t in $tests; do $t || { fail=1 && echo "$t failed" >&2; } done return $fail } run_tests "$@" && echo "completion tests passed" ================================================ FILE: test/test_art_resize.py ================================================ # This file is part of beets. # Copyright 2020, David Swarbrick. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for image resizing based on filesize.""" import os import unittest from pathlib import Path from unittest.mock import patch from beets.test import _common from beets.test.helper import BeetsTestCase, CleanupModulesMixin from beets.util import command_output, syspath from beets.util.artresizer import IMBackend, PILBackend class DummyIMBackend(IMBackend): """An `IMBackend` which pretends that ImageMagick is available. The version is sufficiently recent to support image comparison. """ def __init__(self): """Init a dummy backend class for mocked ImageMagick tests.""" self.version = (7, 0, 0) self.legacy = False self.convert_cmd = ["magick"] self.identify_cmd = ["magick", "identify"] self.compare_cmd = ["magick", "compare"] class DummyPILBackend(PILBackend): """An `PILBackend` which pretends that PIL is available.""" def __init__(self): """Init a dummy backend class for mocked PIL tests.""" pass class ArtResizerFileSizeTest(CleanupModulesMixin, BeetsTestCase): """Unittest test case for Art Resizer to a specific filesize.""" modules = (IMBackend.__module__,) IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg") IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size def _test_img_resize(self, backend): """Test resizing based on file size, given a resize_func.""" # Check quality setting unaffected by new parameter im_95_qual = backend.resize( 225, self.IMG_225x225, quality=95, max_filesize=0, ) # check valid path returned - max_filesize hasn't broken resize command assert Path(os.fsdecode(im_95_qual)).exists() # Attempt a lower filesize with same quality im_a = backend.resize( 225, self.IMG_225x225, quality=95, max_filesize=0.9 * os.stat(syspath(im_95_qual)).st_size, ) assert Path(os.fsdecode(im_a)).exists() # target size was achieved assert ( os.stat(syspath(im_a)).st_size < os.stat(syspath(im_95_qual)).st_size ) # Attempt with lower initial quality im_75_qual = backend.resize( 225, self.IMG_225x225, quality=75, max_filesize=0, ) assert Path(os.fsdecode(im_75_qual)).exists() im_b = backend.resize( 225, self.IMG_225x225, quality=95, max_filesize=0.9 * os.stat(syspath(im_75_qual)).st_size, ) assert Path(os.fsdecode(im_b)).exists() # Check high (initial) quality still gives a smaller filesize assert ( os.stat(syspath(im_b)).st_size < os.stat(syspath(im_75_qual)).st_size ) @unittest.skipUnless(PILBackend.available(), "PIL not available") def test_pil_file_resize(self): """Test PIL resize function is lowering file size.""" self._test_img_resize(PILBackend()) @unittest.skipUnless(IMBackend.available(), "ImageMagick not available") def test_im_file_resize(self): """Test IM resize function is lowering file size.""" self._test_img_resize(IMBackend()) @unittest.skipUnless(PILBackend.available(), "PIL not available") def test_pil_file_deinterlace(self): """Test PIL deinterlace function. Check if the `PILBackend.deinterlace()` function returns images that are non-progressive """ path = PILBackend().deinterlace(self.IMG_225x225) from PIL import Image with Image.open(path) as img: assert "progression" not in img.info @unittest.skipUnless(IMBackend.available(), "ImageMagick not available") def test_im_file_deinterlace(self): """Test ImageMagick deinterlace function. Check if the `IMBackend.deinterlace()` function returns images that are non-progressive. """ im = IMBackend() path = im.deinterlace(self.IMG_225x225) cmd = [ *im.identify_cmd, "-format", "%[interlace]", syspath(path, prefix=False), ] out = command_output(cmd).stdout assert out == b"None" @patch("beets.util.artresizer.util") def test_write_metadata_im(self, mock_util): """Test writing image metadata.""" metadata = {"a": "A", "b": "B"} im = DummyIMBackend() im.write_metadata("foo", metadata) command = [*im.convert_cmd, *"foo -set a A -set b B foo".split()] mock_util.command_output.assert_called_once_with(command) ================================================ FILE: test/test_datequery.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test for dbcore's date-based queries.""" import time import unittest from datetime import datetime, timedelta import pytest from beets.dbcore.query import ( DateInterval, DateQuery, InvalidQueryArgumentValueError, _parse_periods, ) from beets.test.helper import ItemInDBTestCase class TestDateInterval: now = datetime.now().replace(microsecond=0, second=0).isoformat() @pytest.mark.parametrize( "pattern, datestr, include", [ # year precision ("2000..2001", "2000-01-01T00:00:00", True), ("2000..2001", "2001-06-20T14:15:16", True), ("2000..2001", "2001-12-31T23:59:59", True), ("2000..2001", "1999-12-31T23:59:59", False), ("2000..2001", "2002-01-01T00:00:00", False), ("2000..", "2000-01-01T00:00:00", True), ("2000..", "2099-10-11T00:00:00", True), ("2000..", "1999-12-31T23:59:59", False), ("..2001", "2001-12-31T23:59:59", True), ("..2001", "2002-01-01T00:00:00", False), ("-1d..1d", now, True), ("-2d..-1d", now, False), # month precision ("2000-06-20..2000-06-20", "2000-06-20T00:00:00", True), ("2000-06-20..2000-06-20", "2000-06-20T10:20:30", True), ("2000-06-20..2000-06-20", "2000-06-20T23:59:59", True), ("2000-06-20..2000-06-20", "2000-06-19T23:59:59", False), ("2000-06-20..2000-06-20", "2000-06-21T00:00:00", False), # day precision ("1999-12..2000-02", "1999-12-01T00:00:00", True), ("1999-12..2000-02", "2000-02-15T05:06:07", True), ("1999-12..2000-02", "2000-02-29T23:59:59", True), ("1999-12..2000-02", "1999-11-30T23:59:59", False), ("1999-12..2000-02", "2000-03-01T00:00:00", False), # hour precision with 'T' separator ("2000-01-01T12..2000-01-01T13", "2000-01-01T11:59:59", False), ("2000-01-01T12..2000-01-01T13", "2000-01-01T12:00:00", True), ("2000-01-01T12..2000-01-01T13", "2000-01-01T12:30:00", True), ("2000-01-01T12..2000-01-01T13", "2000-01-01T13:30:00", True), ("2000-01-01T12..2000-01-01T13", "2000-01-01T13:59:59", True), ("2000-01-01T12..2000-01-01T13", "2000-01-01T14:00:00", False), ("2000-01-01T12..2000-01-01T13", "2000-01-01T14:30:00", False), # hour precision non-range query ("2008-12-01T22", "2008-12-01T22:30:00", True), ("2008-12-01T22", "2008-12-01T23:30:00", False), # minute precision ("2000-01-01T12:30..2000-01-01T12:31", "2000-01-01T12:29:59", False), ("2000-01-01T12:30..2000-01-01T12:31", "2000-01-01T12:30:00", True), ("2000-01-01T12:30..2000-01-01T12:31", "2000-01-01T12:30:30", True), ("2000-01-01T12:30..2000-01-01T12:31", "2000-01-01T12:31:59", True), ("2000-01-01T12:30..2000-01-01T12:31", "2000-01-01T12:32:00", False), # second precision ("2000-01-01T12:30:50..2000-01-01T12:30:55", "2000-01-01T12:30:49", False), ("2000-01-01T12:30:50..2000-01-01T12:30:55", "2000-01-01T12:30:50", True), ("2000-01-01T12:30:50..2000-01-01T12:30:55", "2000-01-01T12:30:55", True), ("2000-01-01T12:30:50..2000-01-01T12:30:55", "2000-01-01T12:30:56", False), # unbounded # noqa: E501 ("..", datetime.max.isoformat(), True), ("..", datetime.min.isoformat(), True), ("..", "1000-01-01T00:00:00", True), ], ) # fmt: skip def test_intervals(self, pattern, datestr, include): (start, end) = _parse_periods(pattern) interval = DateInterval.from_periods(start, end) assert interval.contains(datetime.fromisoformat(datestr)) == include def _parsetime(s): return time.mktime(datetime.strptime(s, "%Y-%m-%d %H:%M").timetuple()) class DateQueryTest(ItemInDBTestCase): def setUp(self): super().setUp() self.i.added = _parsetime("2013-03-30 22:21") self.i.store() def test_single_month_match_fast(self): query = DateQuery("added", "2013-03") matched = self.lib.items(query) assert len(matched) == 1 def test_single_month_nonmatch_fast(self): query = DateQuery("added", "2013-04") matched = self.lib.items(query) assert len(matched) == 0 def test_single_month_match_slow(self): query = DateQuery("added", "2013-03") assert query.match(self.i) def test_single_month_nonmatch_slow(self): query = DateQuery("added", "2013-04") assert not query.match(self.i) def test_single_day_match_fast(self): query = DateQuery("added", "2013-03-30") matched = self.lib.items(query) assert len(matched) == 1 def test_single_day_nonmatch_fast(self): query = DateQuery("added", "2013-03-31") matched = self.lib.items(query) assert len(matched) == 0 class DateQueryTestRelative(ItemInDBTestCase): def setUp(self): super().setUp() # We pick a date near a month changeover, which can reveal some time # zone bugs. self._now = datetime(2017, 12, 31, 22, 55, 4, 101332) self.i.added = _parsetime(self._now.strftime("%Y-%m-%d %H:%M")) self.i.store() def test_single_month_match_fast(self): query = DateQuery("added", self._now.strftime("%Y-%m")) matched = self.lib.items(query) assert len(matched) == 1 def test_single_month_nonmatch_fast(self): query = DateQuery( "added", (self._now + timedelta(days=30)).strftime("%Y-%m") ) matched = self.lib.items(query) assert len(matched) == 0 def test_single_month_match_slow(self): query = DateQuery("added", self._now.strftime("%Y-%m")) assert query.match(self.i) def test_single_month_nonmatch_slow(self): query = DateQuery( "added", (self._now + timedelta(days=30)).strftime("%Y-%m") ) assert not query.match(self.i) def test_single_day_match_fast(self): query = DateQuery("added", self._now.strftime("%Y-%m-%d")) matched = self.lib.items(query) assert len(matched) == 1 def test_single_day_nonmatch_fast(self): query = DateQuery( "added", (self._now + timedelta(days=1)).strftime("%Y-%m-%d") ) matched = self.lib.items(query) assert len(matched) == 0 class DateQueryTestRelativeMore(ItemInDBTestCase): def setUp(self): super().setUp() self.i.added = _parsetime(datetime.now().strftime("%Y-%m-%d %H:%M")) self.i.store() def test_relative(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"-4{timespan}..+4{timespan}") matched = self.lib.items(query) assert len(matched) == 1 def test_relative_fail(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"-2{timespan}..-1{timespan}") matched = self.lib.items(query) assert len(matched) == 0 def test_start_relative(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"-4{timespan}..") matched = self.lib.items(query) assert len(matched) == 1 def test_start_relative_fail(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"4{timespan}..") matched = self.lib.items(query) assert len(matched) == 0 def test_end_relative(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"..+4{timespan}") matched = self.lib.items(query) assert len(matched) == 1 def test_end_relative_fail(self): for timespan in ["d", "w", "m", "y"]: query = DateQuery("added", f"..-4{timespan}") matched = self.lib.items(query) assert len(matched) == 0 class DateQueryConstructTest(unittest.TestCase): def test_long_numbers(self): with pytest.raises(InvalidQueryArgumentValueError): DateQuery("added", "1409830085..1412422089") def test_too_many_components(self): with pytest.raises(InvalidQueryArgumentValueError): DateQuery("added", "12-34-56-78") def test_invalid_date_query(self): q_list = [ "2001-01-0a", "2001-0a", "200a", "2001-01-01..2001-01-0a", "2001-0a..2001-01", "200a..2002", "20aa..", "..2aa", ] for q in q_list: with pytest.raises(InvalidQueryArgumentValueError): DateQuery("added", q) def test_datetime_uppercase_t_separator(self): date_query = DateQuery("added", "2000-01-01T12") assert date_query.interval.start == datetime(2000, 1, 1, 12) assert date_query.interval.end == datetime(2000, 1, 1, 13) def test_datetime_lowercase_t_separator(self): date_query = DateQuery("added", "2000-01-01t12") assert date_query.interval.start == datetime(2000, 1, 1, 12) assert date_query.interval.end == datetime(2000, 1, 1, 13) def test_datetime_space_separator(self): date_query = DateQuery("added", "2000-01-01 12") assert date_query.interval.start == datetime(2000, 1, 1, 12) assert date_query.interval.end == datetime(2000, 1, 1, 13) def test_datetime_invalid_separator(self): with pytest.raises(InvalidQueryArgumentValueError): DateQuery("added", "2000-01-01x12") ================================================ FILE: test/test_dbcore.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the DBCore database abstraction.""" import os import shutil import sqlite3 import unittest from tempfile import mkstemp from typing import ClassVar import pytest from beets import dbcore from beets.dbcore.db import DBCustomFunctionError, Index from beets.library import LibModel from beets.test import _common from beets.util import cached_classproperty # Fixture: concrete database and model classes. For migration tests, we # have multiple models with different numbers of fields. @pytest.fixture def db(model): db = model(":memory:") yield db db._connection().close() class SortFixture(dbcore.query.FieldSort): pass class QueryFixture(dbcore.query.FieldQuery): def __init__(self, pattern): self.pattern = pattern def clause(self): return None, () def match(self): return True class ModelFixture1(LibModel): _table = "test" _flex_table = "testflex" _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.STRING, } _sorts: ClassVar[dict[str, type[dbcore.query.FieldSort]]] = { "some_sort": SortFixture, } _indices = (Index("field_one_index", ("field_one",)),) @cached_classproperty def _types(cls): return { "some_float_field": dbcore.types.FLOAT, } @cached_classproperty def _queries(cls): return { "some_query": QueryFixture, } @classmethod def _getters(cls): return {} def _template_funcs(self): return {} class DatabaseFixture1(dbcore.Database): _models = (ModelFixture1,) class ModelFixture2(ModelFixture1): _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, } class DatabaseFixture2(dbcore.Database): _models = (ModelFixture2,) class ModelFixture3(ModelFixture1): _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, "field_three": dbcore.types.INTEGER, } class DatabaseFixture3(dbcore.Database): _models = (ModelFixture3,) class ModelFixture4(ModelFixture1): _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "field_one": dbcore.types.INTEGER, "field_two": dbcore.types.INTEGER, "field_three": dbcore.types.INTEGER, "field_four": dbcore.types.INTEGER, } class DatabaseFixture4(dbcore.Database): _models = (ModelFixture4,) class AnotherModelFixture(ModelFixture1): _table = "another" _flex_table = "anotherflex" _fields: ClassVar[dict[str, dbcore.types.Type]] = { "id": dbcore.types.PRIMARY_ID, "foo": dbcore.types.INTEGER, } _indices = (Index("another_foo_index", ("foo",)),) class ModelFixture5(ModelFixture1): _fields: ClassVar[dict[str, dbcore.types.Type]] = { "some_string_field": dbcore.types.STRING, "some_float_field": dbcore.types.FLOAT, "some_boolean_field": dbcore.types.BOOLEAN, } class DatabaseFixture5(dbcore.Database): _models = (ModelFixture5,) class DatabaseFixtureTwoModels(dbcore.Database): _models = (ModelFixture2, AnotherModelFixture) class ModelFixtureWithGetters(dbcore.Model): @classmethod def _getters(cls): return {"aComputedField": (lambda s: "thing")} def _template_funcs(self): return {} @_common.slow_test() class MigrationTest(unittest.TestCase): """Tests the ability to change the database schema between versions. """ @classmethod def setUpClass(cls): handle, cls.orig_libfile = mkstemp("orig_db") os.close(handle) # Set up a database with the two-field schema. old_lib = DatabaseFixture2(cls.orig_libfile) # Add an item to the old library. old_lib._connection().execute( "insert into test (field_one, field_two) values (4, 2)" ) old_lib._connection().commit() old_lib._connection().close() del old_lib @classmethod def tearDownClass(cls): os.remove(cls.orig_libfile) def setUp(self): handle, self.libfile = mkstemp("db") os.close(handle) shutil.copyfile(self.orig_libfile, self.libfile) def tearDown(self): os.remove(self.libfile) def test_open_with_same_fields_leaves_untouched(self): new_lib = DatabaseFixture2(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() c.connection.close() assert len(row.keys()) == len(ModelFixture2._fields) def test_open_with_new_field_adds_column(self): new_lib = DatabaseFixture3(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() c.connection.close() assert len(row.keys()) == len(ModelFixture3._fields) def test_open_with_fewer_fields_leaves_untouched(self): new_lib = DatabaseFixture1(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() c.connection.close() assert len(row.keys()) == len(ModelFixture2._fields) def test_open_with_multiple_new_fields(self): new_lib = DatabaseFixture4(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() c.connection.close() assert len(row.keys()) == len(ModelFixture4._fields) def test_extra_model_adds_table(self): new_lib = DatabaseFixtureTwoModels(self.libfile) try: c = new_lib._connection() c.execute("select * from another") c.close() except sqlite3.OperationalError: self.fail("select failed") def test_index_creation(self): """Test that declared indices are created on database initialization.""" db = DatabaseFixture1(":memory:") with db.transaction() as tx: rows = tx.query("PRAGMA index_info(field_one_index)") assert len(rows) > 0 # Index exists db._connection().close() class TransactionTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(":memory:") def tearDown(self): self.db._connection().close() def test_mutate_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: tx.mutate( f"INSERT INTO {ModelFixture1._table} (field_one) VALUES (?);", (111,), ) assert self.db.revision > old_rev def test_query_no_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: tx.query(f"PRAGMA table_info({ModelFixture1._table})") assert self.db.revision == old_rev class ModelTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(":memory:") def tearDown(self): self.db._connection().close() def test_add_model(self): model = ModelFixture1() model.add(self.db) rows = self.db._connection().execute("select * from test").fetchall() assert len(rows) == 1 def test_store_fixed_field(self): model = ModelFixture1() model.add(self.db) model.field_one = 123 model.store() row = self.db._connection().execute("select * from test").fetchone() assert row["field_one"] == 123 def test_revision(self): old_rev = self.db.revision model = ModelFixture1() model.add(self.db) model.store() assert model._revision == self.db.revision assert self.db.revision > old_rev mid_rev = self.db.revision model2 = ModelFixture1() model2.add(self.db) model2.store() assert model2._revision > mid_rev assert self.db.revision > model._revision # revision changed, so the model should be re-loaded model.load() assert model._revision == self.db.revision # revision did not change, so no reload mod2_old_rev = model2._revision model2.load() assert model2._revision == mod2_old_rev def test_retrieve_by_id(self): model = ModelFixture1() model.add(self.db) other_model = self.db._get(ModelFixture1, model.id) assert model.id == other_model.id def test_store_and_retrieve_flexattr(self): model = ModelFixture1() model.add(self.db) model.foo = "bar" model.store() other_model = self.db._get(ModelFixture1, model.id) assert other_model.foo == "bar" def test_delete_flexattr(self): model = ModelFixture1() model["foo"] = "bar" assert "foo" in model del model["foo"] assert "foo" not in model def test_delete_flexattr_via_dot(self): model = ModelFixture1() model["foo"] = "bar" assert "foo" in model del model.foo assert "foo" not in model def test_delete_flexattr_persists(self): model = ModelFixture1() model.add(self.db) model.foo = "bar" model.store() model = self.db._get(ModelFixture1, model.id) del model["foo"] model.store() model = self.db._get(ModelFixture1, model.id) assert "foo" not in model def test_delete_non_existent_attribute(self): model = ModelFixture1() with pytest.raises(KeyError): del model["foo"] def test_delete_fixed_attribute(self): model = ModelFixture5() model.some_string_field = "foo" model.some_float_field = 1.23 model.some_boolean_field = True for field, type_ in model._fields.items(): assert model[field] != type_.null for field, type_ in model._fields.items(): del model[field] assert model[field] == type_.null def test_null_value_normalization_by_type(self): model = ModelFixture1() model.field_one = None assert model.field_one == 0 def test_null_value_stays_none_for_untyped_field(self): model = ModelFixture1() model.foo = None assert model.foo is None def test_normalization_for_typed_flex_fields(self): model = ModelFixture1() model.some_float_field = None assert model.some_float_field == 0.0 def test_load_deleted_flex_field(self): model1 = ModelFixture1() model1["flex_field"] = True model1.add(self.db) model2 = self.db._get(ModelFixture1, model1.id) assert "flex_field" in model2 del model1["flex_field"] model1.store() model2.load() assert "flex_field" not in model2 def test_check_db_fails(self): with pytest.raises(ValueError, match="no database"): dbcore.Model()._check_db() with pytest.raises(ValueError, match="no id"): ModelFixture1(self.db)._check_db() dbcore.Model(self.db)._check_db(need_id=False) def test_missing_field(self): with pytest.raises(AttributeError): ModelFixture1(self.db).nonExistingKey def test_computed_field(self): model = ModelFixtureWithGetters() assert model.aComputedField == "thing" with pytest.raises(KeyError, match=r"computed field .+ deleted"): del model.aComputedField def test_items(self): model = ModelFixture1(self.db) model.id = 5 assert {("id", 5), ("field_one", 0), ("field_two", "")} == set( model.items() ) def test_delete_internal_field(self): model = dbcore.Model() del model._db with pytest.raises(AttributeError): model._db def test_parse_nonstring(self): with pytest.raises(TypeError, match="must be a string"): dbcore.Model._parse(None, 42) def test_pickle_dump(self): """Tries to pickle an item. This tests the __getstate__ method of the Model ABC""" import pickle model = ModelFixture1(self.db) model.add(self.db) model.field_one = 123 model.store() assert model._db is not None pickle.dumps(model) class FormatTest(unittest.TestCase): def test_format_fixed_field_integer(self): model = ModelFixture1() model.field_one = 155 value = model.formatted().get("field_one") assert value == "155" def test_format_fixed_field_integer_normalized(self): """The normalize method of the Integer class rounds floats""" model = ModelFixture1() model.field_one = 142.432 value = model.formatted().get("field_one") assert value == "142" model.field_one = 142.863 value = model.formatted().get("field_one") assert value == "143" def test_format_fixed_field_string(self): model = ModelFixture1() model.field_two = "caf\xe9" value = model.formatted().get("field_two") assert value == "caf\xe9" def test_format_flex_field(self): model = ModelFixture1() model.other_field = "caf\xe9" value = model.formatted().get("other_field") assert value == "caf\xe9" def test_format_flex_field_bytes(self): model = ModelFixture1() model.other_field = "caf\xe9".encode() value = model.formatted().get("other_field") assert isinstance(value, str) assert value == "caf\xe9" def test_format_unset_field(self): model = ModelFixture1() value = model.formatted().get("other_field") assert value == "" def test_format_typed_flex_field(self): model = ModelFixture1() model.some_float_field = 3.14159265358979 value = model.formatted().get("some_float_field") assert value == "3.1" class FormattedMappingTest(unittest.TestCase): def test_keys_equal_model_keys(self): model = ModelFixture1() formatted = model.formatted() assert set(model.keys(True)) == set(formatted.keys()) def test_get_unset_field(self): model = ModelFixture1() formatted = model.formatted() with pytest.raises(KeyError): formatted["other_field"] def test_get_method_with_default(self): model = ModelFixture1() formatted = model.formatted() assert formatted.get("other_field") == "" def test_get_method_with_specified_default(self): model = ModelFixture1() formatted = model.formatted() assert formatted.get("other_field", "default") == "default" class ParseTest(unittest.TestCase): def test_parse_fixed_field(self): value = ModelFixture1._parse("field_one", "2") assert isinstance(value, int) assert value == 2 def test_parse_flex_field(self): value = ModelFixture1._parse("some_float_field", "2") assert isinstance(value, float) assert value == 2.0 def test_parse_untyped_field(self): value = ModelFixture1._parse("field_nine", "2") assert value == "2" class QueryParseTest(unittest.TestCase): def pqp(self, part): return dbcore.queryparse.parse_query_part( part, {"year": dbcore.query.NumericQuery}, {":": dbcore.query.RegexpQuery}, )[:-1] # remove the negate flag def test_one_basic_term(self): q = "test" r = (None, "test", dbcore.query.SubstringQuery) assert self.pqp(q) == r def test_one_keyed_term(self): q = "test:val" r = ("test", "val", dbcore.query.SubstringQuery) assert self.pqp(q) == r def test_colon_at_end(self): q = "test:" r = ("test", "", dbcore.query.SubstringQuery) assert self.pqp(q) == r def test_one_basic_regexp(self): q = r":regexp" r = (None, "regexp", dbcore.query.RegexpQuery) assert self.pqp(q) == r def test_keyed_regexp(self): q = r"test::regexp" r = ("test", "regexp", dbcore.query.RegexpQuery) assert self.pqp(q) == r def test_escaped_colon(self): q = r"test\:val" r = (None, "test:val", dbcore.query.SubstringQuery) assert self.pqp(q) == r def test_escaped_colon_in_regexp(self): q = r":test\:regexp" r = (None, "test:regexp", dbcore.query.RegexpQuery) assert self.pqp(q) == r def test_single_year(self): q = "year:1999" r = ("year", "1999", dbcore.query.NumericQuery) assert self.pqp(q) == r def test_multiple_years(self): q = "year:1999..2010" r = ("year", "1999..2010", dbcore.query.NumericQuery) assert self.pqp(q) == r def test_empty_query_part(self): q = "" r = (None, "", dbcore.query.SubstringQuery) assert self.pqp(q) == r class QueryFromStringsTest(unittest.TestCase): def qfs(self, strings): return dbcore.queryparse.query_from_strings( dbcore.query.AndQuery, ModelFixture1, {":": dbcore.query.RegexpQuery}, strings, ) def test_zero_parts(self): q = self.qfs([]) assert isinstance(q, dbcore.query.AndQuery) assert len(q.subqueries) == 1 assert isinstance(q.subqueries[0], dbcore.query.TrueQuery) def test_two_parts(self): q = self.qfs(["foo", "bar:baz"]) assert isinstance(q, dbcore.query.AndQuery) assert len(q.subqueries) == 2 assert isinstance(q.subqueries[0], dbcore.query.OrQuery) assert isinstance(q.subqueries[1], dbcore.query.SubstringQuery) def test_parse_fixed_type_query(self): q = self.qfs(["field_one:2..3"]) assert isinstance(q.subqueries[0], dbcore.query.NumericQuery) def test_parse_flex_type_query(self): q = self.qfs(["some_float_field:2..3"]) assert isinstance(q.subqueries[0], dbcore.query.NumericQuery) def test_empty_query_part(self): q = self.qfs([""]) assert isinstance(q.subqueries[0], dbcore.query.TrueQuery) class SortFromStringsTest(unittest.TestCase): def sfs(self, strings): return dbcore.queryparse.sort_from_strings( ModelFixture1, strings, ) def test_zero_parts(self): s = self.sfs([]) assert isinstance(s, dbcore.query.NullSort) assert s == dbcore.query.NullSort() def test_one_parts(self): s = self.sfs(["field+"]) assert isinstance(s, dbcore.query.Sort) def test_two_parts(self): s = self.sfs(["field+", "another_field-"]) assert isinstance(s, dbcore.query.MultipleSort) assert len(s.sorts) == 2 def test_fixed_field_sort(self): s = self.sfs(["field_one+"]) assert isinstance(s, dbcore.query.FixedFieldSort) assert s == dbcore.query.FixedFieldSort("field_one") def test_flex_field_sort(self): s = self.sfs(["flex_field+"]) assert isinstance(s, dbcore.query.SlowFieldSort) assert s == dbcore.query.SlowFieldSort("flex_field") def test_special_sort(self): s = self.sfs(["some_sort+"]) assert isinstance(s, SortFixture) class ParseSortedQueryTest(unittest.TestCase): def psq(self, parts): return dbcore.parse_sorted_query( ModelFixture1, parts.split(), ) def test_and_query(self): q, s = self.psq("foo bar") assert isinstance(q, dbcore.query.AndQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 2 def test_or_query(self): q, s = self.psq("foo , bar") assert isinstance(q, dbcore.query.OrQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 2 def test_no_space_before_comma_or_query(self): q, s = self.psq("foo, bar") assert isinstance(q, dbcore.query.OrQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 2 def test_no_spaces_or_query(self): q, s = self.psq("foo,bar") assert isinstance(q, dbcore.query.AndQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 1 def test_trailing_comma_or_query(self): q, s = self.psq("foo , bar ,") assert isinstance(q, dbcore.query.OrQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 3 def test_leading_comma_or_query(self): q, s = self.psq(", foo , bar") assert isinstance(q, dbcore.query.OrQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 3 def test_only_direction(self): q, s = self.psq("-") assert isinstance(q, dbcore.query.AndQuery) assert isinstance(s, dbcore.query.NullSort) assert len(q.subqueries) == 1 class ResultsIteratorTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(":memory:") model = ModelFixture1() model["foo"] = "baz" model.add(self.db) model = ModelFixture1() model["foo"] = "bar" model.add(self.db) def tearDown(self): self.db._connection().close() def test_iterate_once(self): objs = self.db._fetch(ModelFixture1) assert len(list(objs)) == 2 def test_iterate_twice(self): objs = self.db._fetch(ModelFixture1) list(objs) assert len(list(objs)) == 2 def test_concurrent_iterators(self): results = self.db._fetch(ModelFixture1) it1 = iter(results) it2 = iter(results) next(it1) list(it2) assert len(list(it1)) == 1 def test_slow_query(self): q = dbcore.query.SubstringQuery("foo", "ba", False) objs = self.db._fetch(ModelFixture1, q) assert len(list(objs)) == 2 def test_slow_query_negative(self): q = dbcore.query.SubstringQuery("foo", "qux", False) objs = self.db._fetch(ModelFixture1, q) assert len(list(objs)) == 0 def test_iterate_slow_sort(self): s = dbcore.query.SlowFieldSort("foo") res = self.db._fetch(ModelFixture1, sort=s) objs = list(res) assert objs[0].foo == "bar" assert objs[1].foo == "baz" def test_unsorted_subscript(self): objs = self.db._fetch(ModelFixture1) assert objs[0].foo == "baz" assert objs[1].foo == "bar" def test_slow_sort_subscript(self): s = dbcore.query.SlowFieldSort("foo") objs = self.db._fetch(ModelFixture1, sort=s) assert objs[0].foo == "bar" assert objs[1].foo == "baz" def test_length(self): objs = self.db._fetch(ModelFixture1) assert len(objs) == 2 def test_out_of_range(self): objs = self.db._fetch(ModelFixture1) with pytest.raises(IndexError): objs[100] def test_no_results(self): assert ( self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get() is None ) class TestException: @pytest.mark.parametrize("model", [DatabaseFixture1]) @pytest.mark.filterwarnings( "ignore: .*plz_raise.*: pytest.PytestUnraisableExceptionWarning" ) @pytest.mark.filterwarnings( "error: .*: pytest.PytestUnraisableExceptionWarning" ) def test_custom_function_error(self, db: DatabaseFixture1): def plz_raise(): raise Exception("i haz raized") db._connection().create_function("plz_raise", 0, plz_raise) with db.transaction() as tx: tx.mutate("insert into test (field_one) values (1)") with pytest.raises(DBCustomFunctionError): with db.transaction() as tx: tx.query("select * from test where plz_raise()") ================================================ FILE: test/test_files.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test file manipulation functionality of Item.""" import os import shutil import stat import unittest from os.path import join from pathlib import Path import pytest import beets.library from beets import util from beets.test import _common from beets.test._common import item, touch from beets.test.helper import NEEDS_REFLINK, BeetsTestCase from beets.util import MoveOperation, syspath class MoveTest(BeetsTestCase): def setUp(self): super().setUp() # make a temporary file self.temp_music_file_name = "temp.mp3" self.path = self.temp_dir_path / self.temp_music_file_name shutil.copy(self.resource_path, self.path) # add it to a temporary library self.i = beets.library.Item.from_path(self.path) self.lib.add(self.i) # set up the destination self.lib.path_formats = [ ("default", join("$artist", "$album", "$title")) ] self.i.artist = "one" self.i.album = "two" self.i.title = "three" self.dest = self.lib_path / "one" / "two" / "three.mp3" self.otherdir = self.temp_dir_path / "testotherdir" def test_move_arrives(self): self.i.move() assert self.dest.exists() def test_move_to_custom_dir(self): self.i.move(basedir=os.fsencode(self.otherdir)) assert (self.otherdir / "one" / "two" / "three.mp3").exists() def test_move_departs(self): self.i.move() assert not self.path.exists() def test_move_in_lib_prunes_empty_dir(self): self.i.move() old_path = self.i.filepath assert old_path.exists() self.i.artist = "newArtist" self.i.move() assert not old_path.exists() assert not old_path.parent.exists() def test_copy_arrives(self): self.i.move(operation=MoveOperation.COPY) assert self.dest.exists() def test_copy_does_not_depart(self): self.i.move(operation=MoveOperation.COPY) assert self.path.exists() def test_reflink_arrives(self): self.i.move(operation=MoveOperation.REFLINK_AUTO) assert self.dest.exists() def test_reflink_does_not_depart(self): self.i.move(operation=MoveOperation.REFLINK_AUTO) assert self.path.exists() @NEEDS_REFLINK def test_force_reflink_arrives(self): self.i.move(operation=MoveOperation.REFLINK) assert self.dest.exists() @NEEDS_REFLINK def test_force_reflink_does_not_depart(self): self.i.move(operation=MoveOperation.REFLINK) assert self.path.exists() def test_move_changes_path(self): self.i.move() assert self.i.path == util.normpath(self.dest) def test_copy_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move(operation=MoveOperation.COPY) assert self.i.path == old_path def test_move_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move() assert self.i.path == old_path def test_move_file_with_colon(self): self.i.artist = "C:DOS" self.i.move() assert "C_DOS" in self.i.path.decode() def test_move_file_with_multiple_colons(self): # print(beets.config["replace"]) self.i.artist = "COM:DOS" self.i.move() assert "COM_DOS" in self.i.path.decode() def test_move_file_with_colon_alt_separator(self): old = beets.config["drive_sep_replace"] beets.config["drive_sep_replace"] = "0" self.i.artist = "C:DOS" self.i.move() assert "C0DOS" in self.i.path.decode() beets.config["drive_sep_replace"] = old def test_read_only_file_copied_writable(self): # Make the source file read-only. os.chmod(syspath(self.path), 0o444) try: self.i.move(operation=MoveOperation.COPY) assert os.access(syspath(self.i.path), os.W_OK) finally: # Make everything writable so it can be cleaned up. os.chmod(syspath(self.path), 0o777) os.chmod(syspath(self.i.path), 0o777) def test_move_avoids_collision_with_existing_file(self): # Make a conflicting file at the destination. dest = self.i.destination() os.makedirs(syspath(os.path.dirname(dest))) touch(dest) self.i.move() assert self.i.path != dest assert os.path.dirname(self.i.path) == os.path.dirname(dest) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_arrives(self): self.i.move(operation=MoveOperation.LINK) assert self.dest.exists() assert os.path.islink(syspath(self.dest)) assert self.dest.resolve() == self.path.resolve() @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_does_not_depart(self): self.i.move(operation=MoveOperation.LINK) assert self.path.exists() @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_changes_path(self): self.i.move(operation=MoveOperation.LINK) assert self.i.path == util.normpath(self.dest) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_arrives(self): self.i.move(operation=MoveOperation.HARDLINK) assert self.dest.exists() s1 = os.stat(syspath(self.path)) s2 = os.stat(syspath(self.dest)) assert (s1[stat.ST_INO], s1[stat.ST_DEV]) == ( s2[stat.ST_INO], s2[stat.ST_DEV], ) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_does_not_depart(self): self.i.move(operation=MoveOperation.HARDLINK) assert self.path.exists() @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_changes_path(self): self.i.move(operation=MoveOperation.HARDLINK) assert self.i.path == util.normpath(self.dest) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_from_symlink(self): link_path = join(self.temp_dir, b"temp_link.mp3") link_source = join("./", self.temp_music_file_name) os.symlink(syspath(link_source), syspath(link_path)) self.i.path = link_path self.i.move(operation=MoveOperation.HARDLINK) s1 = os.stat(syspath(self.path)) s2 = os.stat(syspath(self.dest)) assert (s1[stat.ST_INO], s1[stat.ST_DEV]) == ( s2[stat.ST_INO], s2[stat.ST_DEV], ) class HelperTest(unittest.TestCase): def test_ancestry_works_on_file(self): p = "/a/b/c" a = ["/", "/a", "/a/b"] assert util.ancestry(p) == a def test_ancestry_works_on_dir(self): p = "/a/b/c/" a = ["/", "/a", "/a/b", "/a/b/c"] assert util.ancestry(p) == a def test_ancestry_works_on_relative(self): p = "a/b/c" a = ["a", "a/b"] assert util.ancestry(p) == a def test_components_works_on_file(self): p = "/a/b/c" a = ["/", "a", "b", "c"] assert util.components(p) == a def test_components_works_on_dir(self): p = "/a/b/c/" a = ["/", "a", "b", "c"] assert util.components(p) == a def test_components_works_on_relative(self): p = "a/b/c" a = ["a", "b", "c"] assert util.components(p) == a def test_forward_slash(self): p = rb"C:\a\b\c" a = rb"C:/a/b/c" assert util.path_as_posix(p) == a class AlbumFileTest(BeetsTestCase): def setUp(self): super().setUp() # Make library and item. self.lib.path_formats = [ ("default", join("$albumartist", "$album", "$title")) ] self.i = item(self.lib) # Make a file for the item. self.i.path = self.i.destination() util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, b"testotherdir") def test_albuminfo_move_changes_paths(self): self.ai.album = "newAlbumName" self.ai.move() self.ai.store() self.i.load() assert b"newAlbumName" in self.i.path def test_albuminfo_move_moves_file(self): oldpath = self.i.filepath self.ai.album = "newAlbumName" self.ai.move() self.ai.store() self.i.load() assert not oldpath.exists() assert self.i.filepath.exists() def test_albuminfo_move_copies_file(self): oldpath = self.i.filepath self.ai.album = "newAlbumName" self.ai.move(operation=MoveOperation.COPY) self.ai.store() self.i.load() assert oldpath.exists() assert self.i.filepath.exists() @NEEDS_REFLINK def test_albuminfo_move_reflinks_file(self): oldpath = self.i.path self.ai.album = "newAlbumName" self.ai.move(operation=MoveOperation.REFLINK) self.ai.store() self.i.load() assert os.path.exists(oldpath) assert os.path.exists(self.i.path) def test_albuminfo_move_to_custom_dir(self): self.ai.move(basedir=self.otherdir) self.i.load() self.ai.store() assert b"testotherdir" in self.i.path class ArtFileTest(BeetsTestCase): def setUp(self): super().setUp() # Make library and item. self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Make an art file too. art_bytes = self.lib.get_album(self.i).art_destination("something.jpg") self.art = Path(os.fsdecode(art_bytes)) self.art.touch() self.ai.artpath = art_bytes self.ai.store() # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, b"testotherdir") def test_art_deleted_when_items_deleted(self): assert self.art.exists() self.ai.remove(True) assert not self.art.exists() def test_art_moves_with_album(self): assert self.art.exists() oldpath = self.i.path self.ai.album = "newAlbum" self.ai.move() self.i.load() assert self.i.path != oldpath assert not self.art.exists() newart = self.lib.get_album(self.i).art_destination(self.art) assert Path(os.fsdecode(newart)).exists() def test_art_moves_with_album_to_custom_dir(self): # Move the album to another directory. self.ai.move(basedir=self.otherdir) self.ai.store() self.i.load() # Art should be in new directory. assert not self.art.exists() newart = self.lib.get_album(self.i).art_filepath assert newart.exists() assert "testotherdir" in str(newart) def test_setart_copies_image(self): util.remove(self.art) newart = os.path.join(self.libdir, b"newart.jpg") touch(newart) i2 = item() i2.path = self.i.path i2.artist = "someArtist" ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) assert ai.artpath is None ai.set_art(newart) assert ai.art_filepath.exists() def test_setart_to_existing_art_works(self): util.remove(self.art) # Original art. newart = os.path.join(self.libdir, b"newart.jpg") touch(newart) i2 = item() i2.path = self.i.path i2.artist = "someArtist" ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) ai.set_art(newart) # Set the art again. ai.set_art(ai.artpath) assert ai.art_filepath.exists() def test_setart_to_existing_but_unset_art_works(self): newart = os.path.join(self.libdir, b"newart.jpg") touch(newart) i2 = item() i2.path = self.i.path i2.artist = "someArtist" ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # Copy the art to the destination. artdest = ai.art_destination(newart) shutil.copy(syspath(newart), syspath(artdest)) # Set the art again. ai.set_art(artdest) assert ai.art_filepath.exists() def test_setart_to_conflicting_file_gets_new_path(self): newart = os.path.join(self.libdir, b"newart.jpg") touch(newart) i2 = item() i2.path = self.i.path i2.artist = "someArtist" ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # Make a file at the destination. artdest = ai.art_destination(newart) touch(artdest) # Set the art. ai.set_art(newart) assert artdest != ai.artpath assert os.path.dirname(artdest) == os.path.dirname(ai.artpath) def test_setart_sets_permissions(self): util.remove(self.art) newart = os.path.join(self.libdir, b"newart.jpg") touch(newart) os.chmod(syspath(newart), 0o400) # read-only try: i2 = item() i2.path = self.i.path i2.artist = "someArtist" ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) ai.set_art(newart) mode = stat.S_IMODE(os.stat(syspath(ai.artpath)).st_mode) assert mode & stat.S_IRGRP assert os.access(syspath(ai.artpath), os.W_OK) finally: # Make everything writable so it can be cleaned up. os.chmod(syspath(newart), 0o777) os.chmod(syspath(ai.artpath), 0o777) def test_move_last_file_moves_albumart(self): oldartpath = self.lib.albums()[0].art_filepath assert oldartpath.exists() self.ai.album = "different_album" self.ai.store() self.ai.items()[0].move() artpath = self.lib.albums()[0].art_filepath assert "different_album" in str(artpath) assert artpath.exists() assert not oldartpath.exists() def test_move_not_last_file_does_not_move_albumart(self): i2 = item() i2.albumid = self.ai.id self.lib.add(i2) oldartpath = self.lib.albums()[0].art_filepath assert oldartpath.exists() self.i.album = "different_album" self.i.album_id = None # detach from album self.i.move() artpath = self.lib.albums()[0].art_filepath assert "different_album" not in str(artpath) assert artpath == oldartpath assert oldartpath.exists() class RemoveTest(BeetsTestCase): def setUp(self): super().setUp() # Make library and item. self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album with the item. self.ai = self.lib.add_album((self.i,)) def test_removing_last_item_prunes_empty_dir(self): assert self.i.filepath.parent.exists() self.i.remove(True) assert not self.i.filepath.parent.exists() def test_removing_last_item_preserves_nonempty_dir(self): (self.i.filepath.parent / "dummy.txt").touch() self.i.remove(True) assert self.i.filepath.parent.exists() def test_removing_last_item_prunes_dir_with_blacklisted_file(self): (self.i.filepath.parent / ".DS_Store").touch() self.i.remove(True) assert not self.i.filepath.parent.exists() def test_removing_without_delete_leaves_file(self): self.i.remove(False) assert self.i.filepath.parent.exists() def test_removing_last_item_preserves_library_dir(self): self.i.remove(True) assert self.lib_path.exists() def test_removing_item_outside_of_library_deletes_nothing(self): self.lib.directory = os.path.join(self.temp_dir, b"xxx") self.i.remove(True) assert self.i.filepath.parent.exists() def test_removing_last_item_in_album_with_albumart_prunes_dir(self): artfile = os.path.join(self.temp_dir, b"testart.jpg") touch(artfile) self.ai.set_art(artfile) self.ai.store() self.i.remove(True) assert not self.i.filepath.parent.exists() class FilePathTestCase(BeetsTestCase): def setUp(self): super().setUp() self.path = self.temp_dir_path / "testfile" self.path.touch() # Tests that we can "delete" nonexistent files. class SoftRemoveTest(FilePathTestCase): def test_soft_remove_deletes_file(self): util.remove(self.path, True) assert not self.path.exists() def test_soft_remove_silent_on_no_file(self): try: util.remove(self.path / "XXX", True) except OSError: self.fail("OSError when removing path") class SafeMoveCopyTest(FilePathTestCase): def setUp(self): super().setUp() self.otherpath = self.temp_dir_path / "testfile2" self.otherpath.touch() self.dest = Path(f"{self.path}.dest") def test_successful_move(self): util.move(self.path, self.dest) assert self.dest.exists() assert not self.path.exists() def test_successful_copy(self): util.copy(self.path, self.dest) assert self.dest.exists() assert self.path.exists() @NEEDS_REFLINK def test_successful_reflink(self): util.reflink(str(self.path), str(self.dest)) assert self.dest.exists() assert self.path.exists() def test_unsuccessful_move(self): with pytest.raises(util.FilesystemError): util.move(self.path, self.otherpath) def test_unsuccessful_copy(self): with pytest.raises(util.FilesystemError): util.copy(self.path, self.otherpath) def test_unsuccessful_reflink(self): with pytest.raises(util.FilesystemError, match="target exists"): util.reflink(self.path, self.otherpath) def test_self_move(self): util.move(self.path, self.path) assert self.path.exists() def test_self_copy(self): util.copy(self.path, self.path) assert self.path.exists() class PruneTest(BeetsTestCase): def setUp(self): super().setUp() self.base = self.temp_dir_path / "testdir" self.base.mkdir() self.sub = self.base / "subdir" self.sub.mkdir() def test_prune_existent_directory(self): util.prune_dirs(self.sub, self.base) assert self.base.exists() assert not self.sub.exists() def test_prune_nonexistent_directory(self): util.prune_dirs(self.sub / "another", self.base) assert self.base.exists() assert not self.sub.exists() class WalkTest(BeetsTestCase): def setUp(self): super().setUp() self.base = os.path.join(self.temp_dir, b"testdir") os.mkdir(syspath(self.base)) touch(os.path.join(self.base, b"y")) touch(os.path.join(self.base, b"x")) os.mkdir(syspath(os.path.join(self.base, b"d"))) touch(os.path.join(self.base, b"d", b"z")) def test_sorted_files(self): res = list(util.sorted_walk(self.base)) assert len(res) == 2 assert res[0] == (self.base, [b"d"], [b"x", b"y"]) assert res[1] == (os.path.join(self.base, b"d"), [], [b"z"]) def test_ignore_file(self): res = list(util.sorted_walk(self.base, (b"x",))) assert len(res) == 2 assert res[0] == (self.base, [b"d"], [b"y"]) assert res[1] == (os.path.join(self.base, b"d"), [], [b"z"]) def test_ignore_directory(self): res = list(util.sorted_walk(self.base, (b"d",))) assert len(res) == 1 assert res[0] == (self.base, [], [b"x", b"y"]) def test_ignore_everything(self): res = list(util.sorted_walk(self.base, (b"*",))) assert len(res) == 1 assert res[0] == (self.base, [], []) class UniquePathTest(BeetsTestCase): def setUp(self): super().setUp() self.base = os.path.join(self.temp_dir, b"testdir") os.mkdir(syspath(self.base)) touch(os.path.join(self.base, b"x.mp3")) touch(os.path.join(self.base, b"x.1.mp3")) touch(os.path.join(self.base, b"x.2.mp3")) touch(os.path.join(self.base, b"y.mp3")) def test_new_file_unchanged(self): path = util.unique_path(os.path.join(self.base, b"z.mp3")) assert path == os.path.join(self.base, b"z.mp3") def test_conflicting_file_appends_1(self): path = util.unique_path(os.path.join(self.base, b"y.mp3")) assert path == os.path.join(self.base, b"y.1.mp3") def test_conflicting_file_appends_higher_number(self): path = util.unique_path(os.path.join(self.base, b"x.mp3")) assert path == os.path.join(self.base, b"x.3.mp3") def test_conflicting_file_with_number_increases_number(self): path = util.unique_path(os.path.join(self.base, b"x.1.mp3")) assert path == os.path.join(self.base, b"x.3.mp3") class MkDirAllTest(BeetsTestCase): def test_mkdirall(self): child = self.temp_dir_path / "foo" / "bar" / "baz" / "quz.mp3" util.mkdirall(child) assert not child.exists() assert child.parent.exists() assert child.parent.is_dir() ================================================ FILE: test/test_hidden.py ================================================ # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'hidden' utility.""" import ctypes import errno import subprocess import sys import tempfile import unittest from beets import util from beets.util import bytestring_path, hidden class HiddenFileTest(unittest.TestCase): def setUp(self): pass def test_osx_hidden(self): if not sys.platform == "darwin": self.skipTest("sys.platform is not darwin") return with tempfile.NamedTemporaryFile(delete=False) as f: try: command = ["chflags", "hidden", f.name] subprocess.Popen(command).wait() except OSError as e: if e.errno == errno.ENOENT: self.skipTest("unable to find chflags") else: raise e assert hidden.is_hidden(bytestring_path(f.name)) def test_windows_hidden(self): if not sys.platform == "win32": self.skipTest("sys.platform is not windows") return # FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation. hidden_mask = 2 with tempfile.NamedTemporaryFile() as f: # Hide the file using success = ctypes.windll.kernel32.SetFileAttributesW( f.name, hidden_mask ) if not success: self.skipTest("unable to set file attributes") assert hidden.is_hidden(f.name) def test_other_hidden(self): if sys.platform == "darwin" or sys.platform == "win32": self.skipTest("sys.platform is known") return with tempfile.NamedTemporaryFile(prefix=".tmp") as f: fn = util.bytestring_path(f.name) assert hidden.is_hidden(fn) ================================================ FILE: test/test_importer.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the general importer functionality.""" from __future__ import annotations import os import re import shutil import stat import sys import unicodedata import unittest from functools import cached_property from io import StringIO from pathlib import Path from tarfile import TarFile from tempfile import mkstemp from unittest.mock import Mock, patch from zipfile import ZipFile import pytest from mediafile import MediaFile from beets import config, importer, logging, util from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo from beets.importer.tasks import albums_in_dir from beets.test import _common from beets.test.helper import ( NEEDS_REFLINK, AsIsImporterMixin, AutotagImportTestCase, AutotagStub, BeetsTestCase, ImportTestCase, IOMixin, PluginMixin, capture_log, has_program, ) from beets.util import bytestring_path, displayable_path, syspath class PathsMixin: import_media: list[MediaFile] @cached_property def track_import_path(self) -> Path: return Path(self.import_media[0].path) @cached_property def album_path(self) -> Path: return self.track_import_path.parent @cached_property def track_lib_path(self): return self.lib_path / "Tag Artist" / "Tag Album" / "Tag Track 1.mp3" @_common.slow_test() class NonAutotaggedImportTest(PathsMixin, AsIsImporterMixin, ImportTestCase): db_on_disk = True def test_album_created_with_track_artist(self): self.run_asis_importer() albums = self.lib.albums() assert len(albums) == 1 assert albums[0].albumartist == "Tag Artist" def test_import_copy_arrives(self): self.run_asis_importer() assert self.track_lib_path.exists() def test_threaded_import_copy_arrives(self): config["threaded"] = True self.run_asis_importer() assert self.track_lib_path.exists() def test_import_with_move_deletes_import_files(self): assert self.album_path.exists() assert self.track_import_path.exists() (self.album_path / "alog.log").touch() config["clutter"] = ["*.log"] self.run_asis_importer(move=True) assert not self.track_import_path.exists() assert not self.album_path.exists() def test_threaded_import_move_arrives(self): self.run_asis_importer(move=True, threaded=True) assert self.track_lib_path.exists() assert not self.track_import_path.exists() def test_import_without_delete_retains_files(self): self.run_asis_importer(delete=False) assert self.track_import_path.exists() def test_import_with_delete_removes_files(self): self.run_asis_importer(delete=True) assert not self.album_path.exists() assert not self.track_import_path.exists() def test_album_mb_albumartistids(self): self.run_asis_importer() album = self.lib.albums()[0] assert album.mb_albumartistids == album.items()[0].mb_albumartistids @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_import_link_arrives(self): self.run_asis_importer(link=True) assert self.track_lib_path.exists() assert self.track_lib_path.is_symlink() assert self.track_lib_path.resolve() == self.track_import_path.resolve() @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_import_hardlink_arrives(self): self.run_asis_importer(hardlink=True) assert self.track_lib_path.exists() media_stat = self.track_import_path.stat() lib_media_stat = self.track_lib_path.stat() assert media_stat[stat.ST_INO] == lib_media_stat[stat.ST_INO] assert media_stat[stat.ST_DEV] == lib_media_stat[stat.ST_DEV] @NEEDS_REFLINK def test_import_reflink_arrives(self): # Detecting reflinks is currently tricky due to various fs # implementations, we'll just check the file exists. self.run_asis_importer(reflink=True) assert self.track_lib_path.exists() def test_import_reflink_auto_arrives(self): # Should pass regardless of reflink support due to fallback. self.run_asis_importer(reflink="auto") assert self.track_lib_path.exists() def create_archive(session): handle, path = mkstemp(dir=session.temp_dir_path) path = bytestring_path(path) os.close(handle) archive = ZipFile(os.fsdecode(path), mode="w") archive.write(syspath(os.path.join(_common.RSRC, b"full.mp3")), "full.mp3") archive.close() path = bytestring_path(path) return path class RmTempTest(BeetsTestCase): """Tests that temporarily extracted archives are properly removed after usage. """ def setUp(self): super().setUp() self.want_resume = False self.config["incremental"] = False self._old_home = None def test_rm(self): zip_path = create_archive(self) archive_task = importer.ArchiveImportTask(zip_path) archive_task.extract() tmp_path = Path(os.fsdecode(archive_task.toppath)) assert tmp_path.exists() archive_task.finalize(self) assert not tmp_path.exists() class ImportZipTest(AsIsImporterMixin, ImportTestCase): def test_import_zip(self): zip_path = create_archive(self) assert len(self.lib.items()) == 0 assert len(self.lib.albums()) == 0 self.run_asis_importer(import_dir=zip_path) assert len(self.lib.items()) == 1 assert len(self.lib.albums()) == 1 class ImportTarTest(ImportZipTest): def create_archive(self): (handle, path) = mkstemp(dir=syspath(self.temp_dir)) path = bytestring_path(path) os.close(handle) archive = TarFile(os.fsdecode(path), mode="w") archive.add( syspath(os.path.join(_common.RSRC, b"full.mp3")), "full.mp3" ) archive.close() return path @unittest.skipIf(not has_program("unrar"), "unrar program not found") class ImportRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b"archive.rar") class Import7zTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b"archive.7z") @unittest.skip("Implement me!") class ImportPasswordRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b"password.rar") class ImportSingletonTest(AutotagImportTestCase): """Test ``APPLY`` and ``ASIS`` choices for an import session with singletons config set to True. """ def setUp(self): super().setUp() self.prepare_album_for_import(1) self.importer = self.setup_singleton_importer() def test_apply_asis_adds_only_singleton_track(self): self.importer.add_choice(importer.Action.ASIS) self.importer.run() # album not added assert not self.lib.albums() assert self.lib.items().get().title == "Tag Track 1" assert (self.lib_path / "singletons" / "Tag Track 1.mp3").exists() def test_apply_candidate_adds_track(self): self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert not self.lib.albums() assert self.lib.items().get().title == "Applied Track 1" assert (self.lib_path / "singletons" / "Applied Track 1.mp3").exists() def test_apply_from_scratch_removes_other_metadata(self): config["import"]["from_scratch"] = True for mediafile in self.import_media: mediafile.comments = "Tag Comment" mediafile.save() self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().comments == "" def test_skip_does_not_add_track(self): self.importer.add_choice(importer.Action.SKIP) self.importer.run() assert not self.lib.items() def test_skip_first_add_second_asis(self): self.prepare_album_for_import(2) self.importer.add_choice(importer.Action.SKIP) self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert len(self.lib.items()) == 1 def test_import_single_files(self): resource_path = os.path.join(_common.RSRC, b"empty.mp3") single_path = os.path.join(self.import_dir, b"track_2.mp3") util.copy(resource_path, single_path) import_files = [ os.path.join(self.import_dir, b"album"), single_path, ] self.setup_importer() self.importer.paths = import_files self.importer.add_choice(importer.Action.ASIS) self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert len(self.lib.items()) == 2 assert len(self.lib.albums()) == 2 def test_set_fields(self): genres = ["\U0001f3b7 Jazz", "Rock"] collection = "To Listen" disc = 0 config["import"]["set_fields"] = { "genres": "; ".join(genres), "collection": collection, "disc": disc, "title": "$title - formatted", } # As-is item import. assert self.lib.albums().get() is None self.importer.add_choice(importer.Action.ASIS) self.importer.run() for item in self.lib.items(): item.load() # TODO: Not sure this is necessary. assert item.genres == genres assert item.collection == collection assert item.title == "Tag Track 1 - formatted" assert item.disc == disc # Remove item from library to test again with APPLY choice. item.remove() # Autotagged. assert not self.lib.albums() self.importer.clear_choices() self.importer.add_choice(importer.Action.APPLY) self.importer.run() for item in self.lib.items(): item.load() assert item.genres == genres assert item.collection == collection assert item.title == "Applied Track 1 - formatted" assert item.disc == disc class ImportTest(PathsMixin, AutotagImportTestCase): """Test APPLY, ASIS and SKIP choices.""" def setUp(self): super().setUp() self.prepare_album_for_import(1) self.setup_importer() def test_asis_moves_album_and_track(self): self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().album == "Tag Album" item = self.lib.items().get() assert item.title == "Tag Track 1" assert item.filepath.exists() def test_apply_moves_album_and_track(self): self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "Applied Album" item = self.lib.items().get() assert item.title == "Applied Track 1" assert item.filepath.exists() def test_apply_from_scratch_removes_other_metadata(self): config["import"]["from_scratch"] = True for mediafile in self.import_media: mediafile.genres = ["Tag Genre"] mediafile.save() self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert not self.lib.items().get().genres def test_apply_from_scratch_keeps_format(self): config["import"]["from_scratch"] = True self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().format == "MP3" def test_apply_from_scratch_keeps_bitrate(self): config["import"]["from_scratch"] = True bitrate = 80000 self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().bitrate == bitrate def test_apply_with_move_deletes_import(self): assert self.track_import_path.exists() config["import"]["move"] = True self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert not self.track_import_path.exists() def test_apply_with_delete_deletes_import(self): assert self.track_import_path.exists() config["import"]["delete"] = True self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert not self.track_import_path.exists() def test_skip_does_not_add_track(self): self.importer.add_choice(importer.Action.SKIP) self.importer.run() assert not self.lib.items() def test_skip_non_album_dirs(self): assert (self.import_path / "album").exists() self.touch(b"cruft", dir=self.import_dir) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.albums()) == 1 def test_unmatched_tracks_not_added(self): self.prepare_album_for_import(2) self.matcher.matching = self.matcher.MISSING self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.items()) == 1 def test_empty_directory_warning(self): import_dir = os.path.join(self.temp_dir, b"empty") self.touch(b"non-audio", dir=import_dir) self.setup_importer(import_dir=import_dir) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) assert f"No files imported from {import_dir}" in logs def test_empty_directory_singleton_warning(self): import_dir = os.path.join(self.temp_dir, b"empty") self.touch(b"non-audio", dir=import_dir) self.setup_singleton_importer(import_dir=import_dir) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) assert f"No files imported from {import_dir}" in logs def test_asis_no_data_source(self): assert self.lib.items().get() is None self.importer.add_choice(importer.Action.ASIS) self.importer.run() with pytest.raises(AttributeError): self.lib.items().get().data_source def test_set_fields(self): genres = ["\U0001f3b7 Jazz", "Rock"] collection = "To Listen" disc = 0 comments = "managed by beets" config["import"]["set_fields"] = { "genres": "; ".join(genres), "collection": collection, "disc": disc, "comments": comments, "album": "$album - formatted", } # As-is album import. assert self.lib.albums().get() is None self.importer.add_choice(importer.Action.ASIS) self.importer.run() for album in self.lib.albums(): assert album.genres == genres assert album.comments == comments for item in album.items(): assert item.get("genres", with_album=False) == genres assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( item.get("album", with_album=False) == "Tag Album - formatted" ) assert item.disc == disc # Remove album from library to test again with APPLY choice. album.remove() # Autotagged. assert self.lib.albums().get() is None self.importer.clear_choices() self.importer.add_choice(importer.Action.APPLY) self.importer.run() for album in self.lib.albums(): assert album.genres == genres assert album.comments == comments for item in album.items(): assert item.get("genres", with_album=False) == genres assert item.get("collection", with_album=False) == collection assert item.get("comments", with_album=False) == comments assert ( item.get("album", with_album=False) == "Applied Album - formatted" ) assert item.disc == disc class ImportTracksTest(AutotagImportTestCase): """Test TRACKS and APPLY choice.""" def setUp(self): super().setUp() self.prepare_album_for_import(1) self.setup_importer() def test_apply_tracks_adds_singleton_track(self): self.importer.add_choice(importer.Action.TRACKS) self.importer.add_choice(importer.Action.APPLY) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "Applied Track 1" assert not self.lib.albums() def test_apply_tracks_adds_singleton_path(self): self.importer.add_choice(importer.Action.TRACKS) self.importer.add_choice(importer.Action.APPLY) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert (self.lib_path / "singletons" / "Applied Track 1.mp3").exists() class ImportCompilationTest(AutotagImportTestCase): """Test ASIS import of a folder containing tracks with different artists.""" def setUp(self): super().setUp() self.prepare_album_for_import(3) self.setup_importer() def test_asis_homogenous_sets_albumartist(self): self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Tag Artist" for item in self.lib.items(): assert item.albumartist == "Tag Artist" def test_asis_heterogenous_sets_various_albumartist(self): self.import_media[0].artist = "Other Artist" self.import_media[0].save() self.import_media[1].artist = "Another Artist" self.import_media[1].save() self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Various Artists" for item in self.lib.items(): assert item.albumartist == "Various Artists" def test_asis_heterogenous_sets_compilation(self): self.import_media[0].artist = "Other Artist" self.import_media[0].save() self.import_media[1].artist = "Another Artist" self.import_media[1].save() self.importer.add_choice(importer.Action.ASIS) self.importer.run() for item in self.lib.items(): assert item.comp def test_asis_sets_majority_albumartist(self): self.import_media[0].artist = "Other Artist" self.import_media[0].save() self.import_media[1].artist = "Other Artist" self.import_media[1].save() self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Other Artist" for item in self.lib.items(): assert item.albumartist == "Other Artist" def test_asis_albumartist_tag_sets_albumartist(self): self.import_media[0].artist = "Other Artist" self.import_media[1].artist = "Another Artist" for mediafile in self.import_media: mediafile.albumartist = "Album Artist" mediafile.mb_albumartistid = "Album Artist ID" mediafile.save() self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Album Artist" assert self.lib.albums().get().mb_albumartistid == "Album Artist ID" for item in self.lib.items(): assert item.albumartist == "Album Artist" assert item.mb_albumartistid == "Album Artist ID" def test_asis_albumartists_tag_sets_multi_albumartists(self): self.import_media[0].artist = "Other Artist" self.import_media[0].artists = ["Other Artist", "Other Artist 2"] self.import_media[1].artist = "Another Artist" self.import_media[1].artists = ["Another Artist", "Another Artist 2"] for mediafile in self.import_media: mediafile.albumartist = "Album Artist" mediafile.albumartists = ["Album Artist 1", "Album Artist 2"] mediafile.mb_albumartistid = "Album Artist ID" mediafile.save() self.importer.add_choice(importer.Action.ASIS) self.importer.run() assert self.lib.albums().get().albumartist == "Album Artist" assert self.lib.albums().get().albumartists == [ "Album Artist 1", "Album Artist 2", ] assert self.lib.albums().get().mb_albumartistid == "Album Artist ID" # Make sure both custom media items get tested asserted_multi_artists_0 = False asserted_multi_artists_1 = False for item in self.lib.items(): assert item.albumartist == "Album Artist" assert item.albumartists == ["Album Artist 1", "Album Artist 2"] assert item.mb_albumartistid == "Album Artist ID" if item.artist == "Other Artist": asserted_multi_artists_0 = True assert item.artists == ["Other Artist", "Other Artist 2"] if item.artist == "Another Artist": asserted_multi_artists_1 = True assert item.artists == ["Another Artist", "Another Artist 2"] assert asserted_multi_artists_0 assert asserted_multi_artists_1 class ImportExistingTest(PathsMixin, AutotagImportTestCase): """Test importing files that are already in the library directory.""" def setUp(self): super().setUp() self.prepare_album_for_import(1) self.reimporter = self.setup_importer(import_dir=self.libdir) self.importer = self.setup_importer() def tearDown(self): super().tearDown() self.matcher.restore() @cached_property def applied_track_path(self) -> Path: return Path(str(self.track_lib_path).replace("Tag", "Applied")) def test_does_not_duplicate_item_nor_album(self): self.importer.run() assert len(self.lib.items()) == 1 assert len(self.lib.albums()) == 1 self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert len(self.lib.items()) == 1 assert len(self.lib.albums()) == 1 def test_does_not_duplicate_singleton_track(self): self.importer.add_choice(importer.Action.TRACKS) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert len(self.lib.items()) == 1 self.reimporter.add_choice(importer.Action.TRACKS) self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert len(self.lib.items()) == 1 def test_asis_updates_metadata_and_moves_file(self): self.importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = "New Title" medium.save() self.reimporter.add_choice(importer.Action.ASIS) self.reimporter.run() assert self.lib.items().get().title == "New Title" assert not self.applied_track_path.exists() assert self.applied_track_path.with_name("New Title.mp3").exists() def test_asis_updated_without_copy_does_not_move_file(self): self.importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = "New Title" medium.save() config["import"]["copy"] = False self.reimporter.add_choice(importer.Action.ASIS) self.reimporter.run() assert self.applied_track_path.exists() assert not self.applied_track_path.with_name("New Title.mp3").exists() def test_outside_file_is_copied(self): config["import"]["copy"] = False self.importer.run() assert self.lib.items().get().filepath == self.track_import_path self.reimporter = self.setup_importer() self.reimporter.add_choice(importer.Action.APPLY) self.reimporter.run() assert self.applied_track_path.exists() assert self.lib.items().get().filepath == self.applied_track_path class GroupAlbumsImportTest(AutotagImportTestCase): matching = AutotagStub.NONE def setUp(self): super().setUp() self.prepare_album_for_import(3) self.setup_importer() # Split tracks into two albums and use both as-is self.importer.add_choice(importer.Action.ALBUMS) self.importer.add_choice(importer.Action.ASIS) self.importer.add_choice(importer.Action.ASIS) def test_add_album_for_different_artist_and_different_album(self): self.import_media[0].artist = "Artist B" self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} assert albums == {"Album B", "Tag Album"} def test_add_album_for_different_artist_and_same_albumartist(self): self.import_media[0].artist = "Artist B" self.import_media[0].albumartist = "Album Artist" self.import_media[0].save() self.import_media[1].artist = "Artist C" self.import_media[1].albumartist = "Album Artist" self.import_media[1].save() self.importer.run() artists = {album.albumartist for album in self.lib.albums()} assert artists == {"Album Artist", "Tag Artist"} def test_add_album_for_same_artist_and_different_album(self): self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} assert albums == {"Album B", "Tag Album"} def test_add_album_for_same_album_and_different_artist(self): self.import_media[0].artist = "Artist B" self.import_media[0].save() self.importer.run() artists = {album.albumartist for album in self.lib.albums()} assert artists == {"Artist B", "Tag Artist"} def test_incremental(self): config["import"]["incremental"] = True self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} assert albums == {"Album B", "Tag Album"} class GlobalGroupAlbumsImportTest(GroupAlbumsImportTest): def setUp(self): super().setUp() self.importer.clear_choices() self.importer.default_choice = importer.Action.ASIS config["import"]["group_albums"] = True class ChooseCandidateTest(AutotagImportTestCase): matching = AutotagStub.BAD def setUp(self): super().setUp() self.prepare_album_for_import(1) self.setup_importer() def test_choose_first_candidate(self): self.importer.add_choice(1) self.importer.run() assert self.lib.albums().get().album == "Applied Album M" def test_choose_second_candidate(self): self.importer.add_choice(2) self.importer.run() assert self.lib.albums().get().album == "Applied Album MM" class InferAlbumDataTest(unittest.TestCase): def setUp(self): super().setUp() i1 = _common.item() i2 = _common.item() i3 = _common.item() i1.title = "first item" i2.title = "second item" i3.title = "third item" i1.comp = i2.comp = i3.comp = False i1.albumartist = i2.albumartist = i3.albumartist = "" i1.mb_albumartistid = i2.mb_albumartistid = i3.mb_albumartistid = "" self.items = [i1, i2, i3] self.task = importer.ImportTask( paths=["a path"], toppath="top path", items=self.items ) def test_asis_homogenous_single_artist(self): self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert not self.items[0].comp assert self.items[0].albumartist == self.items[2].artist def test_asis_heterogenous_va(self): self.items[0].artist = "another artist" self.items[1].artist = "some other artist" self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert self.items[0].comp assert self.items[0].albumartist == "Various Artists" def test_asis_comp_applied_to_all_items(self): self.items[0].artist = "another artist" self.items[1].artist = "some other artist" self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() for item in self.items: assert item.comp assert item.albumartist == "Various Artists" def test_asis_majority_artist_single_artist(self): self.items[0].artist = "another artist" self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert not self.items[0].comp assert self.items[0].albumartist == self.items[2].artist def test_asis_track_albumartist_override(self): self.items[0].artist = "another artist" self.items[1].artist = "some other artist" for item in self.items: item.albumartist = "some album artist" item.mb_albumartistid = "some album artist id" self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert self.items[0].albumartist == "some album artist" assert self.items[0].mb_albumartistid == "some album artist id" def test_apply_gets_artist_and_id(self): self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self.task.align_album_level_fields() assert self.items[0].albumartist == self.items[0].artist assert self.items[0].mb_albumartistid == self.items[0].mb_artistid def test_apply_lets_album_values_override(self): for item in self.items: item.albumartist = "some album artist" item.mb_albumartistid = "some album artist id" self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self.task.align_album_level_fields() assert self.items[0].albumartist == "some album artist" assert self.items[0].mb_albumartistid == "some album artist id" def test_small_single_artist_album(self): self.items = [self.items[0]] self.task.items = self.items self.task.set_choice(importer.Action.ASIS) self.task.align_album_level_fields() assert not self.items[0].comp def album_candidates_mock(*args, **kwargs): """Create an AlbumInfo object for testing.""" yield AlbumInfo( artist="artist", album="album", tracks=[TrackInfo(title="new title", track_id="trackid", index=0)], album_id="albumid", artist_id="artistid", flex="flex", ) @patch( "beets.metadata_plugins.candidates", Mock(side_effect=album_candidates_mock) ) class ImportDuplicateAlbumTest(PluginMixin, ImportTestCase): plugin = "musicbrainz" def setUp(self): super().setUp() # Original album self.add_album_fixture(albumartist="artist", album="album") # Create import session self.prepare_album_for_import(1) self.importer = self.setup_importer( duplicate_keys={"album": "albumartist album"} ) def test_remove_duplicate_album(self): item = self.lib.items().get() assert item.title == "t\xeftle 0" assert item.filepath.exists() self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() assert not item.filepath.exists() assert len(self.lib.albums()) == 1 assert len(self.lib.items()) == 1 item = self.lib.items().get() assert item.title == "new title" def test_no_autotag_removes_duplicate_album(self): config["import"]["autotag"] = False album = self.lib.albums().get() item = self.lib.items().get() assert item.title == "t\xeftle 0" assert item.filepath.exists() # Imported item has the same albumartist and album as the one in the # library album. We use album metadata (not item metadata) since # duplicate detection uses album-level fields. import_file = os.path.join( self.importer.paths[0], b"album", b"track_1.mp3" ) import_file = MediaFile(import_file) import_file.artist = album.albumartist import_file.albumartist = album.albumartist import_file.album = album.album import_file.title = "new title" import_file.save() self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() # Old duplicate should be removed, new one imported assert len(self.lib.albums()) == 1 assert len(self.lib.items()) == 1 # The new item should be in the library assert self.lib.items().get().title == "new title" def test_keep_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() assert len(self.lib.albums()) == 2 assert len(self.lib.items()) == 2 def test_skip_duplicate_album(self): item = self.lib.items().get() assert item.title == "t\xeftle 0" self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() assert len(self.lib.albums()) == 1 assert len(self.lib.items()) == 1 item = self.lib.items().get() assert item.title == "t\xeftle 0" def test_merge_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.MERGE self.importer.run() assert len(self.lib.albums()) == 1 def test_twice_in_import_dir(self): self.skipTest("write me") def test_keep_when_extra_key_is_different(self): config["import"]["duplicate_keys"]["album"] = "albumartist album flex" item = self.lib.items().get() import_file = MediaFile( os.path.join(self.importer.paths[0], b"album", b"track_1.mp3") ) import_file.artist = item["artist"] import_file.albumartist = item["artist"] import_file.album = item["album"] import_file.title = item["title"] import_file.flex = "different" self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() assert len(self.lib.albums()) == 2 assert len(self.lib.items()) == 2 def add_album_fixture(self, **kwargs): # TODO move this into upstream album = super().add_album_fixture() album.update(kwargs) album.store() return album def item_candidates_mock(*args, **kwargs): yield TrackInfo( artist="artist", title="title", track_id="new trackid", index=0, ) @patch( "beets.metadata_plugins.item_candidates", Mock(side_effect=item_candidates_mock), ) class ImportDuplicateSingletonTest(ImportTestCase): def setUp(self): super().setUp() # Original file in library self.add_item_fixture( artist="artist", title="title", mb_trackid="old trackid" ) # Import session self.prepare_album_for_import(1) self.importer = self.setup_singleton_importer( duplicate_keys={"album": "artist title"} ) def test_remove_duplicate(self): item = self.lib.items().get() assert item.mb_trackid == "old trackid" assert item.filepath.exists() self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() assert not item.filepath.exists() assert len(self.lib.items()) == 1 item = self.lib.items().get() assert item.mb_trackid == "new trackid" def test_keep_duplicate(self): assert len(self.lib.items()) == 1 self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() assert len(self.lib.items()) == 2 def test_skip_duplicate(self): item = self.lib.items().get() assert item.mb_trackid == "old trackid" self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() assert len(self.lib.items()) == 1 item = self.lib.items().get() assert item.mb_trackid == "old trackid" def test_keep_when_extra_key_is_different(self): config["import"]["duplicate_keys"]["item"] = "artist title flex" item = self.lib.items().get() item.flex = "different" item.store() assert len(self.lib.items()) == 1 self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() assert len(self.lib.items()) == 2 def test_no_autotag_removes_duplicate_singleton(self): config["import"]["autotag"] = False item = self.lib.items().get() assert item.mb_trackid == "old trackid" assert item.filepath.exists() # Imported item has the same artist and title as the one in the # library. We use item metadata since duplicate detection uses # item-level fields for singletons. import_file = os.path.join( self.importer.paths[0], b"album", b"track_1.mp3" ) import_file = MediaFile(import_file) import_file.artist = item.artist import_file.title = item.title import_file.mb_trackid = "new trackid" import_file.save() self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() # Old duplicate should be removed, new one imported assert len(self.lib.items()) == 1 # The new item should be in the library assert self.lib.items().get().mb_trackid == "new trackid" def test_twice_in_import_dir(self): self.skipTest("write me") def add_item_fixture(self, **kwargs): # Move this to TestHelper item = self.add_item_fixtures()[0] item.update(kwargs) item.store() return item class TagLogTest(unittest.TestCase): def test_tag_log_line(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log("status", "path") assert "status path" in sio.getvalue() def test_tag_log_unicode(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log("status", "caf\xe9") # send unicode assert "status caf\xe9" in sio.getvalue() class ResumeImportTest(ImportTestCase): @patch("beets.plugins.send") def test_resume_album(self, plugins_send): self.prepare_albums_for_import(2) self.importer = self.setup_importer(autotag=False, resume=True) # Aborts import after one album. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == "album_imported": raise importer.ImportAbortError plugins_send.side_effect = raise_exception self.importer.run() assert len(self.lib.albums()) == 1 assert self.lib.albums("album:'Album 1'").get() is not None self.importer.run() assert len(self.lib.albums()) == 2 assert self.lib.albums("album:'Album 2'").get() is not None @patch("beets.plugins.send") def test_resume_singleton(self, plugins_send): self.prepare_album_for_import(2) self.importer = self.setup_singleton_importer( autotag=False, resume=True ) # Aborts import after one track. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == "item_imported": raise importer.ImportAbortError plugins_send.side_effect = raise_exception self.importer.run() assert len(self.lib.items()) == 1 assert self.lib.items("title:'Track 1'").get() is not None self.importer.run() assert len(self.lib.items()) == 2 assert self.lib.items("title:'Track 1'").get() is not None class IncrementalImportTest(AsIsImporterMixin, ImportTestCase): def test_incremental_album(self): importer = self.run_asis_importer(incremental=True) # Change album name so the original file would be imported again # if incremental was off. album = self.lib.albums().get() album["album"] = "edited album" album.store() importer.run() assert len(self.lib.albums()) == 2 def test_incremental_item(self): importer = self.run_asis_importer(incremental=True, singletons=True) # Change track name so the original file would be imported again # if incremental was off. item = self.lib.items().get() item["artist"] = "edited artist" item.store() importer.run() assert len(self.lib.items()) == 2 def test_invalid_state_file(self): with open(self.config["statefile"].as_filename(), "wb") as f: f.write(b"000") self.run_asis_importer(incremental=True) assert len(self.lib.albums()) == 1 def _mkmp3(path): shutil.copyfile( syspath(os.path.join(_common.RSRC, b"min.mp3")), syspath(path), ) class AlbumsInDirTest(BeetsTestCase): def setUp(self): super().setUp() # create a directory structure for testing self.base = os.path.abspath(os.path.join(self.temp_dir, b"tempdir")) os.mkdir(syspath(self.base)) os.mkdir(syspath(os.path.join(self.base, b"album1"))) os.mkdir(syspath(os.path.join(self.base, b"album2"))) os.mkdir(syspath(os.path.join(self.base, b"more"))) os.mkdir(syspath(os.path.join(self.base, b"more", b"album3"))) os.mkdir(syspath(os.path.join(self.base, b"more", b"album4"))) _mkmp3(os.path.join(self.base, b"album1", b"album1song1.mp3")) _mkmp3(os.path.join(self.base, b"album1", b"album1song2.mp3")) _mkmp3(os.path.join(self.base, b"album2", b"album2song.mp3")) _mkmp3(os.path.join(self.base, b"more", b"album3", b"album3song.mp3")) _mkmp3(os.path.join(self.base, b"more", b"album4", b"album4song.mp3")) def test_finds_all_albums(self): albums = list(albums_in_dir(self.base)) assert len(albums) == 4 def test_separates_contents(self): found = [] for _, album in albums_in_dir(self.base): found.append(re.search(rb"album(.)song", album[0]).group(1)) assert b"1" in found assert b"2" in found assert b"3" in found assert b"4" in found def test_finds_multiple_songs(self): for _, album in albums_in_dir(self.base): n = re.search(rb"album(.)song", album[0]).group(1) if n == b"1": assert len(album) == 2 else: assert len(album) == 1 class MultiDiscAlbumsInDirTest(BeetsTestCase): def create_music(self, files=True, ascii=True): """Create some music in multiple album directories. `files` indicates whether to create the files (otherwise, only directories are made). `ascii` indicates ACII-only filenames; otherwise, we use Unicode names. """ self.base = os.path.abspath(os.path.join(self.temp_dir, b"tempdir")) os.mkdir(syspath(self.base)) name = b"CAT" if ascii else util.bytestring_path("C\xc1T") name_alt_case = b"CAt" if ascii else util.bytestring_path("C\xc1t") self.dirs = [ # Nested album, multiple subdirs. # Also, false positive marker in root dir, and subtitle for disc 3. os.path.join(self.base, b"ABCD1234"), os.path.join(self.base, b"ABCD1234", b"cd 1"), os.path.join(self.base, b"ABCD1234", b"cd 3 - bonus"), # Nested album, single subdir. # Also, punctuation between marker and disc number. os.path.join(self.base, b"album"), os.path.join(self.base, b"album", b"cd _ 1"), # Flattened album, case typo. # Also, false positive marker in parent dir. os.path.join(self.base, b"artist [CD5]"), os.path.join(self.base, b"artist [CD5]", name + b" disc 1"), os.path.join( self.base, b"artist [CD5]", name_alt_case + b" disc 2" ), # Single disc album, sorted between CAT discs. os.path.join(self.base, b"artist [CD5]", name + b"S"), ] self.files = [ os.path.join(self.base, b"ABCD1234", b"cd 1", b"song1.mp3"), os.path.join(self.base, b"ABCD1234", b"cd 3 - bonus", b"song2.mp3"), os.path.join(self.base, b"ABCD1234", b"cd 3 - bonus", b"song3.mp3"), os.path.join(self.base, b"album", b"cd _ 1", b"song4.mp3"), os.path.join( self.base, b"artist [CD5]", name + b" disc 1", b"song5.mp3" ), os.path.join( self.base, b"artist [CD5]", name_alt_case + b" disc 2", b"song6.mp3", ), os.path.join(self.base, b"artist [CD5]", name + b"S", b"song7.mp3"), ] if not ascii: self.dirs = [self._normalize_path(p) for p in self.dirs] self.files = [self._normalize_path(p) for p in self.files] for path in self.dirs: os.mkdir(syspath(path)) if files: for path in self.files: _mkmp3(util.syspath(path)) def _normalize_path(self, path): """Normalize a path's Unicode combining form according to the platform. """ path = path.decode("utf-8") norm_form = "NFD" if sys.platform == "darwin" else "NFC" path = unicodedata.normalize(norm_form, path) return path.encode("utf-8") def test_coalesce_nested_album_multiple_subdirs(self): self.create_music() albums = list(albums_in_dir(self.base)) assert len(albums) == 4 root, items = albums[0] assert root == self.dirs[0:3] assert len(items) == 3 def test_coalesce_nested_album_single_subdir(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[1] assert root == self.dirs[3:5] assert len(items) == 1 def test_coalesce_flattened_album_case_typo(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[2] assert root == self.dirs[6:8] assert len(items) == 2 def test_single_disc_album(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[3] assert root == self.dirs[8:] assert len(items) == 1 def test_do_not_yield_empty_album(self): self.create_music(files=False) albums = list(albums_in_dir(self.base)) assert len(albums) == 0 def test_single_disc_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) root, items = albums[3] assert root == self.dirs[8:] assert len(items) == 1 def test_coalesce_multiple_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) assert len(albums) == 4 root, items = albums[0] assert root == self.dirs[0:3] assert len(items) == 3 class ReimportTest(AutotagImportTestCase): """Test "re-imports", in which the autotagging machinery is used for music that's already in the library. This works by importing new database entries for the same files and replacing the old data with the new data. We also copy over flexible attributes and the added date. """ matching = AutotagStub.GOOD def setUp(self): super().setUp() # The existing album. album = self.add_album_fixture() album.added = 4242.0 album.foo = "bar" # Some flexible attribute. album.data_source = "original_source" album.store() item = album.items().get() item.baz = "qux" item.added = 4747.0 item.store() def _setup_session(self, singletons=False): self.setup_importer(import_dir=self.libdir, singletons=singletons) self.importer.add_choice(importer.Action.APPLY) def _album(self): return self.lib.albums().get() def _item(self): return self.lib.items().get() def test_reimported_album_gets_new_metadata(self): self._setup_session() assert self._album().album == "\xe4lbum" self.importer.run() assert self._album().album == "the album" def test_reimported_album_preserves_flexattr(self): self._setup_session() self.importer.run() assert self._album().foo == "bar" def test_reimported_album_preserves_added(self): self._setup_session() self.importer.run() assert self._album().added == 4242.0 def test_reimported_album_preserves_item_flexattr(self): self._setup_session() self.importer.run() assert self._item().baz == "qux" def test_reimported_album_preserves_item_added(self): self._setup_session() self.importer.run() assert self._item().added == 4747.0 def test_reimported_item_gets_new_metadata(self): self._setup_session(True) assert self._item().title == "t\xeftle 0" self.importer.run() assert self._item().title == "full" def test_reimported_item_preserves_flexattr(self): self._setup_session(True) self.importer.run() assert self._item().baz == "qux" def test_reimported_item_preserves_added(self): self._setup_session(True) self.importer.run() assert self._item().added == 4747.0 def test_reimported_item_preserves_art(self): self._setup_session() art_source = os.path.join(_common.RSRC, b"abbey.jpg") replaced_album = self._album() replaced_album.set_art(art_source) replaced_album.store() old_artpath = replaced_album.art_filepath self.importer.run() new_album = self._album() new_artpath = new_album.art_destination(art_source) assert new_album.artpath == new_artpath assert new_album.art_filepath.exists() if new_artpath != old_artpath: assert not old_artpath.exists() def test_reimported_album_has_new_flexattr(self): self._setup_session() assert self._album().get("bandcamp_album_id") is None self.importer.run() assert self._album().bandcamp_album_id == "bc_url" def test_reimported_album_not_preserves_flexattr(self): self._setup_session() self.importer.run() assert self._album().data_source == "match_source" class ImportPretendTest(IOMixin, AutotagImportTestCase): """Test the pretend commandline option""" def setUp(self): super().setUp() self.album_track_path = self.prepare_album_for_import(1)[0] self.single_path = self.prepare_track_for_import(2, self.import_path) self.album_path = self.album_track_path.parent def __run(self, importer): with capture_log() as logs: importer.run() assert len(self.lib.items()) == 0 assert len(self.lib.albums()) == 0 return [line for line in logs if not line.startswith("Sending event:")] assert self._album().data_source == "original_source" def test_import_singletons_pretend(self): assert self.__run(self.setup_singleton_importer(pretend=True)) == [ f"Singleton: {self.single_path}", f"Singleton: {self.album_track_path}", ] def test_import_album_pretend(self): assert self.__run(self.setup_importer(pretend=True)) == [ f"Album: {self.import_path}", f" {self.single_path}", f"Album: {self.album_path}", f" {self.album_track_path}", ] def test_import_pretend_empty(self): empty_path = self.temp_dir_path / "empty" empty_path.mkdir() importer = self.setup_importer(pretend=True, import_dir=empty_path) assert self.__run(importer) == [f"No files imported from {empty_path}"] def mocked_get_albums_by_ids(ids): """Return album candidate for the given id. The two albums differ only in the release title and artist name, so that ID_RELEASE_0 is a closer match to the items created by ImportHelper.prepare_album_for_import(). """ # Map IDs to (release title, artist), so the distances are different. album_artist_map = { ImportIdTest.ID_RELEASE_0: ("VALID_RELEASE_0", "TAG ARTIST"), ImportIdTest.ID_RELEASE_1: ("VALID_RELEASE_1", "DISTANT_MATCH"), } for id_ in ids: album, artist = album_artist_map[id_] yield AlbumInfo( album_id=id_, album=album, artist_id="some-id", artist=artist, albumstatus="Official", tracks=[ TrackInfo( track_id="bar", title="foo", artist_id="some-id", artist=artist, length=59, index=9, track_allt="A2", ) ], ) def mocked_get_tracks_by_ids(ids): """Return track candidate for the given id. The two tracks differ only in the release title and artist name, so that ID_RELEASE_0 is a closer match to the items created by ImportHelper.prepare_album_for_import(). """ # Map IDs to (recording title, artist), so the distances are different. title_artist_map = { ImportIdTest.ID_RECORDING_0: ("VALID_RECORDING_0", "TAG ARTIST"), ImportIdTest.ID_RECORDING_1: ("VALID_RECORDING_1", "DISTANT_MATCH"), } for id_ in ids: title, artist = title_artist_map[id_] yield TrackInfo( track_id=id_, title=title, artist_id="some-id", artist=artist, length=59, ) @patch( "beets.metadata_plugins.tracks_for_ids", Mock(side_effect=mocked_get_tracks_by_ids), ) @patch( "beets.metadata_plugins.albums_for_ids", Mock(side_effect=mocked_get_albums_by_ids), ) class ImportIdTest(ImportTestCase): ID_RELEASE_0 = "00000000-0000-0000-0000-000000000000" ID_RELEASE_1 = "11111111-1111-1111-1111-111111111111" ID_RECORDING_0 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" ID_RECORDING_1 = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" def setUp(self): super().setUp() self.prepare_album_for_import(1) def test_one_mbid_one_album(self): self.setup_importer(search_ids=[self.ID_RELEASE_0]) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "VALID_RELEASE_0" def test_several_mbid_one_album(self): self.setup_importer(search_ids=[self.ID_RELEASE_0, self.ID_RELEASE_1]) self.importer.add_choice(2) # Pick the 2nd best match (release 1). self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.albums().get().album == "VALID_RELEASE_1" def test_one_mbid_one_singleton(self): self.setup_singleton_importer(search_ids=[self.ID_RECORDING_0]) self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "VALID_RECORDING_0" def test_several_mbid_one_singleton(self): self.setup_singleton_importer( search_ids=[self.ID_RECORDING_0, self.ID_RECORDING_1] ) self.importer.add_choice(2) # Pick the 2nd best match (recording 1). self.importer.add_choice(importer.Action.APPLY) self.importer.run() assert self.lib.items().get().title == "VALID_RECORDING_1" def test_candidates_album(self): """Test directly ImportTask.lookup_candidates().""" task = importer.ImportTask( paths=self.import_dir, toppath="top path", items=[_common.item()] ) task.lookup_candidates([self.ID_RELEASE_0, self.ID_RELEASE_1]) assert {"VALID_RELEASE_0", "VALID_RELEASE_1"} == { c.info.album for c in task.candidates } def test_candidates_singleton(self): """Test directly SingletonImportTask.lookup_candidates().""" task = importer.SingletonImportTask( toppath="top path", item=_common.item() ) task.lookup_candidates([self.ID_RECORDING_0, self.ID_RECORDING_1]) assert {"VALID_RECORDING_0", "VALID_RECORDING_1"} == { c.info.title for c in task.candidates } ================================================ FILE: test/test_library.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for non-query database functions of Item.""" import os import os.path import re import shutil import stat import unicodedata import unittest from unittest.mock import patch import pytest from mediafile import MediaFile, UnreadableFileError import beets.dbcore.query import beets.library import beets.logging as blog from beets import config, plugins, util from beets.library import Album from beets.test import _common from beets.test._common import item from beets.test.helper import BeetsTestCase, ItemInDBTestCase, capture_log from beets.util import as_string, bytestring_path, normpath, syspath # Shortcut to path normalization. np = util.normpath class LoadTest(ItemInDBTestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = "something" self.i.load() assert original_title == self.i.title def test_load_clears_dirty_flags(self): self.i.artist = "something" assert "artist" in self.i._dirty self.i.load() assert "artist" not in self.i._dirty class StoreTest(ItemInDBTestCase): def test_store_changes_database_value(self): new_year = 1987 self.i.year = new_year self.i.store() assert self.lib.get_item(self.i.id).year == new_year def test_store_only_writes_dirty_fields(self): new_year = 1987 self.i._values_fixed["year"] = new_year # change w/o dirtying self.i.store() assert self.lib.get_item(self.i.id).year != new_year def test_store_clears_dirty_flags(self): self.i.composer = "tvp" self.i.store() assert "composer" not in self.i._dirty def test_store_album_cascades_flex_deletes(self): album = Album(flex1="Flex-1") self.lib.add(album) item = _common.item() item.album_id = album.id item.flex1 = "Flex-1" self.lib.add(item) del album.flex1 album.store() assert "flex1" not in album assert "flex1" not in album.items()[0] class AddTest(BeetsTestCase): def setUp(self): super().setUp() self.i = item() def test_item_add_inserts_row(self): self.lib.add(self.i) new_grouping = ( self.lib._connection() .execute( "select grouping from items where composer = ?", (self.i.composer,), ) .fetchone()["grouping"] ) assert new_grouping == self.i.grouping def test_library_add_path_inserts_row(self): i = beets.library.Item.from_path( os.path.join(_common.RSRC, b"full.mp3") ) self.lib.add(i) new_grouping = ( self.lib._connection() .execute( "select grouping from items where composer = ?", (self.i.composer,), ) .fetchone()["grouping"] ) assert new_grouping == self.i.grouping def test_library_add_one_database_change_event(self): """Test library.add emits only one database_change event.""" self.item = _common.item() self.item.path = beets.util.normpath( os.path.join( self.temp_dir, b"a", b"b.mp3", ) ) self.item.album = "a" self.item.title = "b" blog.getLogger("beets").set_global_level(blog.DEBUG) with capture_log() as logs: self.lib.add(self.item) assert logs.count("Sending event: database_change") == 1 class RemoveTest(ItemInDBTestCase): def test_remove_deletes_from_db(self): self.i.remove() c = self.lib._connection().execute("select * from items") assert c.fetchone() is None class GetSetTest(BeetsTestCase): def setUp(self): super().setUp() self.i = item() def test_set_changes_value(self): self.i.bpm = 4915 assert self.i.bpm == 4915 def test_set_sets_dirty_flag(self): self.i.comp = not self.i.comp assert "comp" in self.i._dirty def test_set_does_not_dirty_if_value_unchanged(self): self.i.title = self.i.title assert "title" not in self.i._dirty def test_invalid_field_raises_attributeerror(self): with pytest.raises(AttributeError): self.i.xyzzy def test_album_fallback(self): # integration test of item-album fallback i = item(self.lib) album = self.lib.add_album([i]) album["flex"] = "foo" album.store() assert "flex" in i assert "flex" not in i.keys(with_album=False) assert i["flex"] == "foo" assert i.get("flex") == "foo" assert i.get("flex", with_album=False) is None assert i.get("flexx") is None class DestinationTest(BeetsTestCase): """Confirm tests handle temporary directory path containing '.'""" def create_temp_dir(self, **kwargs): kwargs["prefix"] = "." return super().create_temp_dir(**kwargs) def setUp(self): super().setUp() self.i = item(self.lib) def test_directory_works_with_trailing_slash(self): self.lib.directory = b"one/" self.lib.path_formats = [("default", "two")] assert self.i.destination() == np("one/two") def test_directory_works_without_trailing_slash(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "two")] assert self.i.destination() == np("one/two") def test_destination_substitutes_metadata_values(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$album/$artist $title")] self.i.title = "three" self.i.artist = "two" self.i.album = "one" assert self.i.destination() == np("base/one/two three") def test_destination_preserves_extension(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$title")] self.i.path = "hey.audioformat" assert self.i.destination() == np("base/the title.audioformat") def test_lower_case_extension(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$title")] self.i.path = "hey.MP3" assert self.i.destination() == np("base/the title.mp3") def test_destination_pads_some_indices(self): self.lib.directory = b"base" self.lib.path_formats = [ ("default", "$track $tracktotal $disc $disctotal $bpm") ] self.i.track = 1 self.i.tracktotal = 2 self.i.disc = 3 self.i.disctotal = 4 self.i.bpm = 5 assert self.i.destination() == np("base/01 02 03 04 5") def test_destination_pads_date_values(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$year-$month-$day")] self.i.year = 1 self.i.month = 2 self.i.day = 3 assert self.i.destination() == np("base/0001-02-03") def test_destination_escapes_slashes(self): self.i.album = "one/two" dest = self.i.destination() assert b"one" in dest assert b"two" in dest assert b"one/two" not in dest def test_destination_escapes_leading_dot(self): self.i.album = ".something" dest = self.i.destination() assert b"something" in dest assert b"/.something" not in dest def test_destination_preserves_legitimate_slashes(self): self.i.artist = "one" self.i.album = "two" dest = self.i.destination() assert os.path.join(b"one", b"two") in dest def test_destination_long_names_truncated(self): self.i.title = "X" * 300 self.i.artist = "Y" * 300 for c in self.i.destination().split(util.PATH_SEP): assert len(c) <= 255 def test_destination_long_names_keep_extension(self): self.i.title = "X" * 300 self.i.path = b"something.extn" dest = self.i.destination() assert dest[-5:] == b".extn" def test_distination_windows_removes_both_separators(self): self.i.title = "one \\ two / three.mp3" with _common.platform_windows(): p = self.i.destination() assert b"one \\ two" not in p assert b"one / two" not in p assert b"two \\ three" not in p assert b"two / three" not in p def test_path_with_format(self): self.lib.path_formats = [("default", "$artist/$album ($format)")] p = self.i.destination() assert b"(FLAC)" in p def test_heterogeneous_album_gets_single_directory(self): i1, i2 = item(), item() self.lib.add_album([i1, i2]) i1.year, i2.year = 2009, 2010 self.lib.path_formats = [("default", "$album ($year)/$track $title")] dest1, dest2 = i1.destination(), i2.destination() assert os.path.dirname(dest1) == os.path.dirname(dest2) def test_default_path_for_non_compilations(self): self.i.comp = False self.lib.add_album([self.i]) self.lib.directory = b"one" self.lib.path_formats = [("default", "two"), ("comp:true", "three")] assert self.i.destination() == np("one/two") def test_singleton_path(self): i = item(self.lib) self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("singleton:true", "four"), ("comp:true", "three"), ] assert i.destination() == np("one/four") def test_comp_before_singleton_path(self): i = item(self.lib) i.comp = True self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("comp:true", "three"), ("singleton:true", "four"), ] assert i.destination() == np("one/three") def test_comp_path(self): self.i.comp = True self.lib.add_album([self.i]) self.lib.directory = b"one" self.lib.path_formats = [("default", "two"), ("comp:true", "three")] assert self.i.destination() == np("one/three") def test_albumtype_query_path(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = "sometype" self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("albumtype:sometype", "four"), ("comp:true", "three"), ] assert self.i.destination() == np("one/four") def test_albumtype_path_fallback_to_comp(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = "sometype" self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("albumtype:anothertype", "four"), ("comp:true", "three"), ] assert self.i.destination() == np("one/three") def test_get_formatted_does_not_replace_separators(self): with _common.platform_posix(): name = os.path.join("a", "b") self.i.title = name newname = self.i.formatted().get("title") assert name == newname def test_get_formatted_pads_with_zero(self): with _common.platform_posix(): self.i.track = 1 name = self.i.formatted().get("track") assert name.startswith("0") def test_get_formatted_uses_kbps_bitrate(self): with _common.platform_posix(): self.i.bitrate = 12345 val = self.i.formatted().get("bitrate") assert val == "12kbps" def test_get_formatted_uses_khz_samplerate(self): with _common.platform_posix(): self.i.samplerate = 12345 val = self.i.formatted().get("samplerate") assert val == "12kHz" def test_get_formatted_datetime(self): with _common.platform_posix(): self.i.added = 1368302461.210265 val = self.i.formatted().get("added") assert val.startswith("2013") def test_get_formatted_none(self): with _common.platform_posix(): self.i.some_other_field = None val = self.i.formatted().get("some_other_field") assert val == "" def test_artist_falls_back_to_albumartist(self): self.i.artist = "" self.i.albumartist = "something" self.lib.path_formats = [("default", "$artist")] p = self.i.destination() assert p.rsplit(util.PATH_SEP, 1)[1] == b"something" def test_albumartist_falls_back_to_artist(self): self.i.artist = "trackartist" self.i.albumartist = "" self.lib.path_formats = [("default", "$albumartist")] p = self.i.destination() assert p.rsplit(util.PATH_SEP, 1)[1] == b"trackartist" def test_artist_overrides_albumartist(self): self.i.artist = "theartist" self.i.albumartist = "something" self.lib.path_formats = [("default", "$artist")] p = self.i.destination() assert p.rsplit(util.PATH_SEP, 1)[1] == b"theartist" def test_albumartist_overrides_artist(self): self.i.artist = "theartist" self.i.albumartist = "something" self.lib.path_formats = [("default", "$albumartist")] p = self.i.destination() assert p.rsplit(util.PATH_SEP, 1)[1] == b"something" def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize("NFC", "caf\xe9") self.lib.path_formats = [("default", instr)] with patch("sys.platform", "darwin"): dest = self.i.destination(relative_to_libdir=True) assert as_string(dest) == unicodedata.normalize("NFD", instr) def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize("NFD", "caf\xe9") self.lib.path_formats = [("default", instr)] with patch("sys.platform", "linux"): dest = self.i.destination(relative_to_libdir=True) assert as_string(dest) == unicodedata.normalize("NFC", instr) def test_unicode_extension_in_fragment(self): self.lib.path_formats = [("default", "foo")] self.i.path = util.bytestring_path("bar.caf\xe9") with patch("sys.platform", "linux"): dest = self.i.destination(relative_to_libdir=True) assert as_string(dest) == "foo.caf\xe9" def test_asciify_and_replace(self): config["asciify_paths"] = True self.lib.replacements = [(re.compile('"'), "q")] self.lib.directory = b"lib" self.lib.path_formats = [("default", "$title")] self.i.title = "\u201c\u00f6\u2014\u00cf\u201d" assert self.i.destination() == np("lib/qo--Iq") def test_asciify_character_expanding_to_slash(self): config["asciify_paths"] = True self.lib.directory = b"lib" self.lib.path_formats = [("default", "$title")] self.i.title = "ab\xa2\xbdd" assert self.i.destination() == np("lib/abC_ 1_2d") def test_destination_with_replacements(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"a"), "e")] self.lib.path_formats = [("default", "$album/$title")] self.i.title = "foo" self.i.album = "bar" assert self.i.destination() == np("base/ber/foo") @unittest.skip("unimplemented: #359") def test_destination_with_empty_component(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"^$"), "_")] self.lib.path_formats = [("default", "$album/$artist/$title")] self.i.title = "three" self.i.artist = "" self.i.albumartist = "" self.i.album = "one" assert self.i.destination() == np("base/one/_/three") @unittest.skip("unimplemented: #359") def test_destination_with_empty_final_component(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"^$"), "_")] self.lib.path_formats = [("default", "$album/$title")] self.i.title = "" self.i.album = "one" self.i.path = "foo.mp3" assert self.i.destination() == np("base/one/_.mp3") def test_album_field_query(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "two"), ("flex:foo", "three")] album = self.lib.add_album([self.i]) assert self.i.destination() == np("one/two") album["flex"] = "foo" album.store() assert self.i.destination() == np("one/three") def test_album_field_in_template(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "$flex/two")] album = self.lib.add_album([self.i]) album["flex"] = "foo" album.store() assert self.i.destination() == np("one/foo/two") class ItemFormattedMappingTest(ItemInDBTestCase): def test_formatted_item_value(self): formatted = self.i.formatted() assert formatted["artist"] == "the artist" def test_get_unset_field(self): formatted = self.i.formatted() with pytest.raises(KeyError): formatted["other_field"] def test_get_method_with_default(self): formatted = self.i.formatted() assert formatted.get("other_field") == "" def test_get_method_with_specified_default(self): formatted = self.i.formatted() assert formatted.get("other_field", "default") == "default" def test_item_precedence(self): album = self.lib.add_album([self.i]) album["artist"] = "foo" album.store() assert "foo" != self.i.formatted().get("artist") def test_album_flex_field(self): album = self.lib.add_album([self.i]) album["flex"] = "foo" album.store() assert "foo" == self.i.formatted().get("flex") def test_album_field_overrides_item_field_for_path(self): # Make the album inconsistent with the item. album = self.lib.add_album([self.i]) album.album = "foo" album.store() self.i.album = "bar" self.i.store() # Ensure the album takes precedence. formatted = self.i.formatted(for_path=True) assert formatted["album"] == "foo" def test_artist_falls_back_to_albumartist(self): self.i.artist = "" formatted = self.i.formatted() assert formatted["artist"] == "the album artist" def test_albumartist_falls_back_to_artist(self): self.i.albumartist = "" formatted = self.i.formatted() assert formatted["albumartist"] == "the artist" def test_both_artist_and_albumartist_empty(self): self.i.artist = "" self.i.albumartist = "" formatted = self.i.formatted() assert formatted["albumartist"] == "" class PathFormattingMixin: """Utilities for testing path formatting.""" i: beets.library.Item lib: beets.library.Library def _setf(self, fmt): self.lib.path_formats.insert(0, ("default", fmt)) def _assert_dest(self, dest, i=None): if i is None: i = self.i # Handle paths on Windows. if os.path.sep != "/": dest = dest.replace(b"/", os.path.sep.encode()) # Paths are normalized based on the CWD. dest = normpath(dest) actual = i.destination() assert actual == dest class DestinationFunctionTest(BeetsTestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i = item(self.lib) def test_upper_case_literal(self): self._setf("%upper{foo}") self._assert_dest(b"/base/FOO") def test_upper_case_variable(self): self._setf("%upper{$title}") self._assert_dest(b"/base/THE TITLE") def test_capitalize_variable(self): self._setf("%capitalize{$title}") self._assert_dest(b"/base/The title") def test_title_case_variable(self): self._setf("%title{$title}") self._assert_dest(b"/base/The Title") def test_title_case_variable_aphostrophe(self): self._setf("%title{I can't}") self._assert_dest(b"/base/I Can't") def test_asciify_variable(self): self._setf("%asciify{ab\xa2\xbdd}") self._assert_dest(b"/base/abC_ 1_2d") def test_left_variable(self): self._setf("%left{$title, 3}") self._assert_dest(b"/base/the") def test_right_variable(self): self._setf("%right{$title,3}") self._assert_dest(b"/base/tle") def test_if_false(self): self._setf("x%if{,foo}") self._assert_dest(b"/base/x") def test_if_false_value(self): self._setf("x%if{false,foo}") self._assert_dest(b"/base/x") def test_if_true(self): self._setf("%if{bar,foo}") self._assert_dest(b"/base/foo") def test_if_else_false(self): self._setf("%if{,foo,baz}") self._assert_dest(b"/base/baz") def test_if_else_false_value(self): self._setf("%if{false,foo,baz}") self._assert_dest(b"/base/baz") def test_if_int_value(self): self._setf("%if{0,foo,baz}") self._assert_dest(b"/base/baz") def test_nonexistent_function(self): self._setf("%foo{bar}") self._assert_dest(b"/base/%foo{bar}") def test_if_def_field_return_self(self): self.i.bar = 3 self._setf("%ifdef{bar}") self._assert_dest(b"/base/3") def test_if_def_field_not_defined(self): self._setf(" %ifdef{bar}/$artist") self._assert_dest(b"/base/the artist") def test_if_def_field_not_defined_2(self): self._setf("$artist/%ifdef{bar}") self._assert_dest(b"/base/the artist") def test_if_def_true(self): self._setf("%ifdef{artist,cool}") self._assert_dest(b"/base/cool") def test_if_def_true_complete(self): self.i.series = "Now" self._setf("%ifdef{series,$series Series,Albums}/$album") self._assert_dest(b"/base/Now Series/the album") def test_if_def_false_complete(self): self._setf("%ifdef{plays,$plays,not_played}") self._assert_dest(b"/base/not_played") def test_first(self): self.i.albumtypes = ["album", "compilation"] self._setf("%first{$albumtypes}") self._assert_dest(b"/base/album") def test_first_skip(self): self.i.albumtype = "album; ep; compilation" self._setf("%first{$albumtype,1,2}") self._assert_dest(b"/base/compilation") def test_first_different_sep(self): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._assert_dest(b"/base/Alice & Bob") class DisambiguationTest(BeetsTestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i1 = item() self.i1.year = 2001 self.lib.add_album([self.i1]) self.i2 = item() self.i2.year = 2002 self.lib.add_album([self.i2]) self.lib._connection().commit() self._setf("foo%aunique{albumartist album,year}/$title") def test_unique_expands_to_disambiguating_year(self): self._assert_dest(b"/base/foo [2001]/the title", self.i1) def test_unique_with_default_arguments_uses_albumtype(self): album2 = self.lib.get_album(self.i1) album2.albumtype = "bar" album2.store() self._setf("foo%aunique{}/$title") self._assert_dest(b"/base/foo [bar]/the title", self.i1) def test_unique_expands_to_nothing_for_distinct_albums(self): album2 = self.lib.get_album(self.i2) album2.album = "different album" album2.store() self._assert_dest(b"/base/foo/the title", self.i1) def test_use_fallback_numbers_when_identical(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album2.store() self._assert_dest(b"/base/foo [1]/the title", self.i1) self._assert_dest(b"/base/foo [2]/the title", self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf("foo%aunique{albumartist album,month year}/$title") self._assert_dest(b"/base/foo [2001]/the title", self.i1) def test_unique_sanitized(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album1 = self.lib.get_album(self.i1) album1.albumtype = "foo/bar" album2.store() album1.store() self._setf("foo%aunique{albumartist album,albumtype}/$title") self._assert_dest(b"/base/foo [foo_bar]/the title", self.i1) def test_drop_empty_disambig_string(self): album1 = self.lib.get_album(self.i1) album1.albumdisambig = None album2 = self.lib.get_album(self.i2) album2.albumdisambig = "foo" album1.store() album2.store() self._setf("foo%aunique{albumartist album,albumdisambig}/$title") self._assert_dest(b"/base/foo/the title", self.i1) def test_change_brackets(self): self._setf("foo%aunique{albumartist album,year,()}/$title") self._assert_dest(b"/base/foo (2001)/the title", self.i1) def test_remove_brackets(self): self._setf("foo%aunique{albumartist album,year,}/$title") self._assert_dest(b"/base/foo 2001/the title", self.i1) def test_key_flexible_attribute(self): album1 = self.lib.get_album(self.i1) album1.flex = "flex1" album2 = self.lib.get_album(self.i2) album2.flex = "flex2" album1.store() album2.store() self._setf("foo%aunique{albumartist album flex,year}/$title") self._assert_dest(b"/base/foo/the title", self.i1) class SingletonDisambiguationTest(BeetsTestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i1 = item() self.i1.year = 2001 self.lib.add(self.i1) self.i2 = item() self.i2.year = 2002 self.lib.add(self.i2) self.lib._connection().commit() self._setf("foo/$title%sunique{artist title,year}") def test_sunique_expands_to_disambiguating_year(self): self._assert_dest(b"/base/foo/the title [2001]", self.i1) def test_sunique_with_default_arguments_uses_trackdisambig(self): self.i1.trackdisambig = "live version" self.i1.year = self.i2.year self.i1.store() self._setf("foo/$title%sunique{}") self._assert_dest(b"/base/foo/the title [live version]", self.i1) def test_sunique_expands_to_nothing_for_distinct_singletons(self): self.i2.title = "different track" self.i2.store() self._assert_dest(b"/base/foo/the title", self.i1) def test_sunique_does_not_match_album(self): self.lib.add_album([self.i2]) self._assert_dest(b"/base/foo/the title", self.i1) def test_sunique_use_fallback_numbers_when_identical(self): self.i2.year = self.i1.year self.i2.store() self._assert_dest(b"/base/foo/the title [1]", self.i1) self._assert_dest(b"/base/foo/the title [2]", self.i2) def test_sunique_falls_back_to_second_distinguishing_field(self): self._setf("foo/$title%sunique{albumartist album,month year}") self._assert_dest(b"/base/foo/the title [2001]", self.i1) def test_sunique_sanitized(self): self.i2.year = self.i1.year self.i1.trackdisambig = "foo/bar" self.i2.store() self.i1.store() self._setf("foo/$title%sunique{artist title,trackdisambig}") self._assert_dest(b"/base/foo/the title [foo_bar]", self.i1) def test_drop_empty_disambig_string(self): self.i1.trackdisambig = None self.i2.trackdisambig = "foo" self.i1.store() self.i2.store() self._setf("foo/$title%sunique{albumartist album,trackdisambig}") self._assert_dest(b"/base/foo/the title", self.i1) def test_change_brackets(self): self._setf("foo/$title%sunique{artist title,year,()}") self._assert_dest(b"/base/foo/the title (2001)", self.i1) def test_remove_brackets(self): self._setf("foo/$title%sunique{artist title,year,}") self._assert_dest(b"/base/foo/the title 2001", self.i1) def test_key_flexible_attribute(self): self.i1.flex = "flex1" self.i2.flex = "flex2" self.i1.store() self.i2.store() self._setf("foo/$title%sunique{artist title flex,year}") self._assert_dest(b"/base/foo/the title", self.i1) class PluginDestinationTest(BeetsTestCase): def setUp(self): super().setUp() # Mock beets.plugins.item_field_getters. self._tv_map = {} def field_getters(): getters = {} for key, value in self._tv_map.items(): getters[key] = lambda _: value return getters self.old_field_getters = plugins.item_field_getters plugins.item_field_getters = field_getters self.lib.directory = b"/base" self.lib.path_formats = [("default", "$artist $foo")] self.i = item(self.lib) def tearDown(self): super().tearDown() plugins.item_field_getters = self.old_field_getters def _assert_dest(self, dest): with _common.platform_posix(): the_dest = self.i.destination() assert the_dest == b"/base/" + dest def test_undefined_value_not_substituted(self): self._assert_dest(b"the artist $foo") def test_plugin_value_not_substituted(self): self._tv_map = { "foo": "bar", } self._assert_dest(b"the artist bar") def test_plugin_value_overrides_attribute(self): self._tv_map = { "artist": "bar", } self._assert_dest(b"bar $foo") def test_plugin_value_sanitized(self): self._tv_map = { "foo": "bar/baz", } self._assert_dest(b"the artist bar_baz") class AlbumInfoTest(BeetsTestCase): def setUp(self): super().setUp() self.i = item() self.lib.add_album((self.i,)) def test_albuminfo_reflects_metadata(self): ai = self.lib.get_album(self.i) assert ai.mb_albumartistid == self.i.mb_albumartistid assert ai.albumartist == self.i.albumartist assert ai.album == self.i.album assert ai.year == self.i.year def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) ai.artpath = "/my/great/art" ai.store() new_ai = self.lib.get_album(self.i) assert new_ai.artpath == b"/my/great/art" def test_albuminfo_for_two_items_doesnt_duplicate_row(self): i2 = item(self.lib) self.lib.get_album(self.i) self.lib.get_album(i2) c = self.lib._connection().cursor() c.execute("select * from albums where album=?", (self.i.album,)) # Cursor should only return one row. assert c.fetchone() is not None assert c.fetchone() is None def test_individual_tracks_have_no_albuminfo(self): i2 = item() i2.album = "aTotallyDifferentAlbum" self.lib.add(i2) ai = self.lib.get_album(i2) assert ai is None def test_get_album_by_id(self): ai = self.lib.get_album(self.i) ai = self.lib.get_album(self.i.id) assert ai is not None def test_album_items_consistent(self): ai = self.lib.get_album(self.i) for i in ai.items(): if i.id == self.i.id: break else: self.fail("item not found") def test_albuminfo_changes_affect_items(self): ai = self.lib.get_album(self.i) ai.album = "myNewAlbum" ai.store() i = self.lib.items()[0] assert i.album == "myNewAlbum" def test_albuminfo_change_albumartist_changes_items(self): ai = self.lib.get_album(self.i) ai.albumartist = "myNewArtist" ai.store() i = self.lib.items()[0] assert i.albumartist == "myNewArtist" assert i.artist != "myNewArtist" def test_albuminfo_change_artist_does_change_items(self): ai = self.lib.get_album(self.i) ai.artist = "myNewArtist" ai.store(inherit=True) i = self.lib.items()[0] assert i.artist == "myNewArtist" def test_albuminfo_change_artist_does_not_change_items(self): ai = self.lib.get_album(self.i) ai.artist = "myNewArtist" ai.store(inherit=False) i = self.lib.items()[0] assert i.artist != "myNewArtist" def test_albuminfo_remove_removes_items(self): item_id = self.i.id self.lib.get_album(self.i).remove() c = self.lib._connection().execute( "SELECT id FROM items WHERE id=?", (item_id,) ) assert c.fetchone() is None def test_removing_last_item_removes_album(self): assert len(self.lib.albums()) == 1 self.i.remove() assert len(self.lib.albums()) == 0 def test_noop_albuminfo_changes_affect_items(self): i = self.lib.items()[0] i.album = "foobar" i.store() ai = self.lib.get_album(self.i) ai.album = ai.album ai.store() i = self.lib.items()[0] assert i.album == ai.album class ArtDestinationTest(BeetsTestCase): def setUp(self): super().setUp() config["art_filename"] = "artimage" config["replace"] = {"X": "Y"} self.lib.replacements = [(re.compile("X"), "Y")] self.i = item(self.lib) self.i.path = self.i.destination() self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): art = self.ai.art_destination("something.jpg") new_art = bytestring_path(f"{os.path.sep}artimage.jpg") assert new_art in art def test_art_path_in_item_dir(self): art = self.ai.art_destination("something.jpg") track = self.i.destination() assert os.path.dirname(art) == os.path.dirname(track) def test_art_path_sanitized(self): config["art_filename"] = "artXimage" art = self.ai.art_destination("something.jpg") assert b"artYimage" in art class PathStringTest(BeetsTestCase): def setUp(self): super().setUp() self.i = item(self.lib) def test_item_path_is_bytestring(self): assert isinstance(self.i.path, bytes) def test_fetched_item_path_is_bytestring(self): i = next(iter(self.lib.items())) assert isinstance(i.path, bytes) def test_unicode_path_becomes_bytestring(self): self.i.path = "unicodepath" assert isinstance(self.i.path, bytes) def test_unicode_in_database_becomes_bytestring(self): self.lib._connection().execute( """ update items set path=? where id=? """, (self.i.id, "somepath"), ) i = next(iter(self.lib.items())) assert isinstance(i.path, bytes) def test_special_chars_preserved_in_database(self): path = "b\xe1r".encode() self.i.path = path self.i.store() i = next(iter(self.lib.items())) assert i.path == path def test_special_char_path_added_to_database(self): self.i.remove() path = "b\xe1r".encode() i = item() i.path = path self.lib.add(i) i = next(iter(self.lib.items())) assert i.path == path def test_destination_returns_bytestring(self): self.i.artist = "b\xe1r" dest = self.i.destination() assert isinstance(dest, bytes) def test_art_destination_returns_bytestring(self): self.i.artist = "b\xe1r" alb = self.lib.add_album([self.i]) dest = alb.art_destination("image.jpg") assert isinstance(dest, bytes) def test_artpath_stores_special_chars(self): path = b"b\xe1r" alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() alb = self.lib.get_album(self.i) assert path == alb.artpath def test_sanitize_path_with_special_chars(self): path = "b\xe1r?" new_path = util.sanitize_path(path) assert new_path.startswith("b\xe1r") def test_sanitize_path_returns_unicode(self): path = "b\xe1r?" new_path = util.sanitize_path(path) assert isinstance(new_path, str) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) alb.artpath = "somep\xe1th" assert isinstance(alb.artpath, bytes) def test_unicode_artpath_in_database_decoded(self): alb = self.lib.add_album([self.i]) self.lib._connection().execute( "update albums set artpath=? where id=?", ("somep\xe1th", alb.id) ) alb = self.lib.get_album(alb.id) assert isinstance(alb.artpath, bytes) class MtimeTest(BeetsTestCase): def setUp(self): super().setUp() self.ipath = os.path.join(self.temp_dir, b"testfile.mp3") shutil.copy( syspath(os.path.join(_common.RSRC, b"full.mp3")), syspath(self.ipath), ) self.i = beets.library.Item.from_path(self.ipath) self.lib.add(self.i) def tearDown(self): super().tearDown() if os.path.exists(self.ipath): os.remove(self.ipath) def _mtime(self): return int(os.path.getmtime(self.ipath)) def test_mtime_initially_up_to_date(self): assert self.i.mtime >= self._mtime() def test_mtime_reset_on_db_modify(self): self.i.title = "something else" assert self.i.mtime < self._mtime() def test_mtime_up_to_date_after_write(self): self.i.title = "something else" self.i.write() assert self.i.mtime >= self._mtime() def test_mtime_up_to_date_after_read(self): self.i.title = "something else" self.i.read() assert self.i.mtime >= self._mtime() class ImportTimeTest(BeetsTestCase): def added(self): self.track = item() self.album = self.lib.add_album((self.track,)) assert self.album.added > 0 assert self.track.added > 0 def test_atime_for_singleton(self): self.singleton = item(self.lib) assert self.singleton.added > 0 class TemplateTest(ItemInDBTestCase): def test_year_formatted_in_template(self): self.i.year = 123 self.i.store() assert self.i.evaluate_template("$year") == "0123" def test_album_flexattr_appears_in_item_template(self): self.album = self.lib.add_album([self.i]) self.album.foo = "baz" self.album.store() assert self.i.evaluate_template("$foo") == "baz" def test_album_and_item_format(self): config["format_album"] = "foö $foo" album = beets.library.Album() album.foo = "bar" album.tagada = "togodo" assert f"{album}" == "foö bar" assert f"{album:$tagada}" == "togodo" assert str(album) == "foö bar" assert bytes(album) == b"fo\xc3\xb6 bar" config["format_item"] = "bar $foo" item = beets.library.Item() item.foo = "bar" item.tagada = "togodo" assert f"{item}" == "bar bar" assert f"{item:$tagada}" == "togodo" class UnicodePathTest(ItemInDBTestCase): def test_unicode_path(self): self.i.path = os.path.join(_common.RSRC, "unicode\u2019d.mp3".encode()) # If there are any problems with unicode paths, we will raise # here and fail. self.i.read() self.i.write() class WriteTest(BeetsTestCase): def test_write_nonexistant(self): item = self.create_item() item.path = b"/path/does/not/exist" with pytest.raises(beets.library.ReadError): item.write() def test_no_write_permission(self): item = self.add_item_fixture() path = syspath(item.path) os.chmod(path, stat.S_IRUSR) try: with pytest.raises(beets.library.WriteError): item.write() finally: # Restore write permissions so the file can be cleaned up. os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) def test_write_with_custom_path(self): item = self.add_item_fixture() custom_path = os.path.join(self.temp_dir, b"custom.mp3") shutil.copy(syspath(item.path), syspath(custom_path)) item["artist"] = "new artist" assert MediaFile(syspath(custom_path)).artist != "new artist" assert MediaFile(syspath(item.path)).artist != "new artist" item.write(custom_path) assert MediaFile(syspath(custom_path)).artist == "new artist" assert MediaFile(syspath(item.path)).artist != "new artist" def test_write_custom_tags(self): item = self.add_item_fixture(artist="old artist") item.write(tags={"artist": "new artist"}) assert item.artist != "new artist" assert MediaFile(syspath(item.path)).artist == "new artist" def test_write_multi_tags(self): item = self.add_item_fixture(artist="old artist") item.write(tags={"artists": ["old artist", "another artist"]}) assert MediaFile(syspath(item.path)).artists == [ "old artist", "another artist", ] def test_write_multi_tags_id3v23(self): item = self.add_item_fixture(artist="old artist") item.write( tags={"artists": ["old artist", "another artist"]}, id3v23=True ) assert MediaFile(syspath(item.path)).artists == [ "old artist/another artist" ] def test_write_date_field(self): # Since `date` is not a MediaField, this should do nothing. item = self.add_item_fixture() clean_year = item.year item.date = "foo" item.write() assert MediaFile(syspath(item.path)).year == clean_year class ItemReadTest(unittest.TestCase): def test_unreadable_raise_read_error(self): unreadable = os.path.join(_common.RSRC, b"image-2x3.png") item = beets.library.Item() with pytest.raises(beets.library.ReadError) as exc_info: item.read(unreadable) assert isinstance(exc_info.value.reason, UnreadableFileError) def test_nonexistent_raise_read_error(self): item = beets.library.Item() with pytest.raises(beets.library.ReadError): item.read("/thisfiledoesnotexist") class FilesizeTest(BeetsTestCase): def test_filesize(self): item = self.add_item_fixture() assert item.filesize != 0 def test_nonexistent_file(self): item = beets.library.Item() assert item.filesize == 0 class ParseQueryTest(unittest.TestCase): def test_parse_invalid_query_string(self): with pytest.raises(beets.dbcore.query.ParsingError): beets.library.parse_query_string('foo"', None) def test_parse_bytes(self): with pytest.raises(AssertionError): beets.library.parse_query_string(b"query", None) ================================================ FILE: test/test_logging.py ================================================ """Stupid tests that ensure logging works as expected""" import logging as log import sys import threading from types import ModuleType from unittest.mock import patch import pytest import beets.logging as blog from beets import plugins, ui from beets.test import _common, helper from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin class TestStrFormatLogger: """Tests for the custom str-formatting logger.""" def test_logger_creation(self): l1 = log.getLogger("foo123") l2 = blog.getLogger("foo123") assert l1 == l2 assert l1.__class__ == log.Logger l3 = blog.getLogger("bar123") l4 = log.getLogger("bar123") assert l3 == l4 assert l3.__class__ == blog.BeetsLogger assert isinstance( l3, (blog.StrFormatLogger, blog.ThreadLocalLevelLogger) ) l5 = l3.getChild("shalala") assert l5.__class__ == blog.BeetsLogger l6 = blog.getLogger() assert l1 != l6 @pytest.mark.parametrize( "level", [log.DEBUG, log.INFO, log.WARNING, log.ERROR] ) @pytest.mark.parametrize( "msg, args, kwargs, expected", [ ("foo {} bar {}", ("oof", "baz"), {}, "foo oof bar baz"), ( "foo {bar} baz {foo}", (), {"foo": "oof", "bar": "baz"}, "foo baz baz oof", ), ("no args", (), {}, "no args"), ("foo {} bar {baz}", ("oof",), {"baz": "baz"}, "foo oof bar baz"), ], ) def test_str_format_logging( self, level, msg, args, kwargs, expected, caplog ): logger = blog.getLogger("test_logger") logger.setLevel(level) with caplog.at_level(level, logger="test_logger"): logger.log(level, msg, *args, **kwargs) assert caplog.records, "No log records were captured" assert str(caplog.records[0].msg) == expected class TestLogSanitization: """Log messages should have control characters removed from: - String arguments - Keyword argument values - Bytes arguments (which get decoded first) """ @pytest.mark.parametrize( "msg, args, kwargs, expected", [ # Valid UTF-8 bytes are decoded and preserved ( "foo {} bar {bar}", (b"oof \xc3\xa9",), {"bar": b"baz \xc3\xa9"}, "foo oof é bar baz é", ), # Invalid UTF-8 bytes are decoded with replacement characters ( "foo {} bar {bar}", (b"oof \xff",), {"bar": b"baz \xff"}, "foo oof � bar baz �", ), # Control characters should be removed ( "foo {} bar {bar}", ("oof \x9e",), {"bar": "baz \x9e"}, "foo oof � bar baz �", ), # Whitespace control characters should be preserved ( "foo {} bar {bar}", ("foo\t\n",), {"bar": "bar\r"}, "foo foo\t\n bar bar\r", ), ], ) def test_sanitization(self, msg, args, kwargs, expected, caplog): level = log.INFO logger = blog.getLogger("test_logger") logger.setLevel(level) with caplog.at_level(level, logger="test_logger"): logger.log(level, msg, *args, **kwargs) assert caplog.records, "No log records were captured" assert str(caplog.records[0].msg) == expected class DummyModule(ModuleType): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): plugins.BeetsPlugin.__init__(self, "dummy") self.import_stages = [self.import_stage] self.register_listener("dummy_event", self.listener) def log_all(self, name): self._log.debug("debug {}", name) self._log.info("info {}", name) self._log.warning("warning {}", name) def commands(self): cmd = ui.Subcommand("dummy") cmd.func = lambda _, __, ___: self.log_all("cmd") return (cmd,) def import_stage(self, session, task): self.log_all("import_stage") def listener(self): self.log_all("listener") def __init__(self, *_, **__): module_name = "beetsplug.dummy" super().__init__(module_name) self.DummyPlugin.__module__ = module_name self.DummyPlugin = self.DummyPlugin class LoggingLevelTest(AsIsImporterMixin, PluginMixin, ImportTestCase): plugin = "dummy" @classmethod def setUpClass(cls): patcher = patch.dict(sys.modules, {"beetsplug.dummy": DummyModule()}) patcher.start() cls.addClassCleanup(patcher.stop) super().setUpClass() def test_command_level0(self): self.config["verbose"] = 0 with helper.capture_log() as logs: self.run_command("dummy") assert "dummy: warning cmd" in logs assert "dummy: info cmd" in logs assert "dummy: debug cmd" not in logs def test_command_level1(self): self.config["verbose"] = 1 with helper.capture_log() as logs: self.run_command("dummy") assert "dummy: warning cmd" in logs assert "dummy: info cmd" in logs assert "dummy: debug cmd" in logs def test_command_level2(self): self.config["verbose"] = 2 with helper.capture_log() as logs: self.run_command("dummy") assert "dummy: warning cmd" in logs assert "dummy: info cmd" in logs assert "dummy: debug cmd" in logs def test_listener_level0(self): self.config["verbose"] = 0 with helper.capture_log() as logs: plugins.send("dummy_event") assert "dummy: warning listener" in logs assert "dummy: info listener" not in logs assert "dummy: debug listener" not in logs def test_listener_level1(self): self.config["verbose"] = 1 with helper.capture_log() as logs: plugins.send("dummy_event") assert "dummy: warning listener" in logs assert "dummy: info listener" in logs assert "dummy: debug listener" not in logs def test_listener_level2(self): self.config["verbose"] = 2 with helper.capture_log() as logs: plugins.send("dummy_event") assert "dummy: warning listener" in logs assert "dummy: info listener" in logs assert "dummy: debug listener" in logs def test_import_stage_level0(self): self.config["verbose"] = 0 with helper.capture_log() as logs: self.run_asis_importer() assert "dummy: warning import_stage" in logs assert "dummy: info import_stage" not in logs assert "dummy: debug import_stage" not in logs def test_import_stage_level1(self): self.config["verbose"] = 1 with helper.capture_log() as logs: self.run_asis_importer() assert "dummy: warning import_stage" in logs assert "dummy: info import_stage" in logs assert "dummy: debug import_stage" not in logs def test_import_stage_level2(self): self.config["verbose"] = 2 with helper.capture_log() as logs: self.run_asis_importer() assert "dummy: warning import_stage" in logs assert "dummy: info import_stage" in logs assert "dummy: debug import_stage" in logs @_common.slow_test() class ConcurrentEventsTest(AsIsImporterMixin, ImportTestCase): """Similar to LoggingLevelTest but lower-level and focused on multiple events interaction. Since this is a bit heavy we don't do it in LoggingLevelTest. """ db_on_disk = True class DummyPlugin(plugins.BeetsPlugin): def __init__(self, test_case): plugins.BeetsPlugin.__init__(self, "dummy") self.register_listener("dummy_event1", self.listener1) self.register_listener("dummy_event2", self.listener2) self.lock1 = threading.Lock() self.lock2 = threading.Lock() self.test_case = test_case self.exc = None self.t1_step = self.t2_step = 0 def log_all(self, name): self._log.debug("debug {}", name) self._log.info("info {}", name) self._log.warning("warning {}", name) def listener1(self): try: assert self._log.level == log.INFO self.t1_step = 1 self.lock1.acquire() assert self._log.level == log.INFO self.t1_step = 2 except Exception as e: self.exc = e def listener2(self): try: assert self._log.level == log.DEBUG self.t2_step = 1 self.lock2.acquire() assert self._log.level == log.DEBUG self.t2_step = 2 except Exception as e: self.exc = e def test_concurrent_events(self): dp = self.DummyPlugin(self) def check_dp_exc(): if dp.exc: raise dp.exc try: dp.lock1.acquire() dp.lock2.acquire() assert dp._log.level == log.NOTSET self.config["verbose"] = 1 t1 = threading.Thread(target=dp.listeners["dummy_event1"][0]) t1.start() # blocked. t1 tested its log level while dp.t1_step != 1: check_dp_exc() assert t1.is_alive() assert dp._log.level == log.NOTSET self.config["verbose"] = 2 t2 = threading.Thread(target=dp.listeners["dummy_event2"][0]) t2.start() # blocked. t2 tested its log level while dp.t2_step != 1: check_dp_exc() assert t2.is_alive() assert dp._log.level == log.NOTSET dp.lock1.release() # dummy_event1 tests its log level + finishes while dp.t1_step != 2: check_dp_exc() t1.join(0.1) assert not t1.is_alive() assert t2.is_alive() assert dp._log.level == log.NOTSET dp.lock2.release() # dummy_event2 tests its log level + finishes while dp.t2_step != 2: check_dp_exc() t2.join(0.1) assert not t2.is_alive() except Exception: print("Alive threads:", threading.enumerate()) if dp.lock1.locked(): print("Releasing lock1 after exception in test") dp.lock1.release() if dp.lock2.locked(): print("Releasing lock2 after exception in test") dp.lock2.release() print("Alive threads:", threading.enumerate()) raise def test_root_logger_levels(self): """Root logger level should be shared between threads.""" self.config["threaded"] = True blog.getLogger("beets").set_global_level(blog.WARNING) with helper.capture_log() as logs: self.run_asis_importer() assert logs == [] blog.getLogger("beets").set_global_level(blog.INFO) with helper.capture_log() as logs: self.run_asis_importer() for line in logs: assert "import" in line assert "album" in line blog.getLogger("beets").set_global_level(blog.DEBUG) with helper.capture_log() as logs: self.run_asis_importer() assert "Sending event: database_change" in logs ================================================ FILE: test/test_m3ufile.py ================================================ # This file is part of beets. # Copyright 2022, J0J0 Todos. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Testsuite for the M3UFile class.""" import sys import unittest from os import path from shutil import rmtree from tempfile import mkdtemp import pytest from beets.test._common import RSRC from beets.util import bytestring_path from beets.util.m3u import EmptyPlaylistError, M3UFile class M3UFileTest(unittest.TestCase): """Tests the M3UFile class.""" def test_playlist_write_empty(self): """Test whether saving an empty playlist file raises an error.""" tempdir = bytestring_path(mkdtemp()) the_playlist_file = path.join(tempdir, b"playlist.m3u8") m3ufile = M3UFile(the_playlist_file) with pytest.raises(EmptyPlaylistError): m3ufile.write() rmtree(tempdir) def test_playlist_write(self): """Test saving ascii paths to a playlist file.""" tempdir = bytestring_path(mkdtemp()) the_playlist_file = path.join(tempdir, b"playlist.m3u") m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents( [ bytestring_path("/This/is/a/path/to_a_file.mp3"), bytestring_path("/This/is/another/path/to_a_file.mp3"), ] ) m3ufile.write() assert path.exists(the_playlist_file) rmtree(tempdir) def test_playlist_write_unicode(self): """Test saving unicode paths to a playlist file.""" tempdir = bytestring_path(mkdtemp()) the_playlist_file = path.join(tempdir, b"playlist.m3u8") m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents( [ bytestring_path("/This/is/å/path/to_a_file.mp3"), bytestring_path("/This/is/another/path/tö_a_file.mp3"), ] ) m3ufile.write() assert path.exists(the_playlist_file) rmtree(tempdir) @unittest.skipUnless(sys.platform == "win32", "win32") def test_playlist_write_and_read_unicode_windows(self): """Test saving unicode paths to a playlist file on Windows.""" tempdir = bytestring_path(mkdtemp()) the_playlist_file = path.join( tempdir, b"playlist_write_and_read_windows.m3u8" ) m3ufile = M3UFile(the_playlist_file) m3ufile.set_contents( [ bytestring_path(r"x:\This\is\å\path\to_a_file.mp3"), bytestring_path(r"x:\This\is\another\path\tö_a_file.mp3"), ] ) m3ufile.write() assert path.exists(the_playlist_file) m3ufile_read = M3UFile(the_playlist_file) m3ufile_read.load() assert m3ufile.media_list[0] == bytestring_path( path.join("x:\\", "This", "is", "å", "path", "to_a_file.mp3") ) assert m3ufile.media_list[1] == bytestring_path( r"x:\This\is\another\path\tö_a_file.mp3" ), bytestring_path( path.join("x:\\", "This", "is", "another", "path", "tö_a_file.mp3") ) rmtree(tempdir) @unittest.skipIf(sys.platform == "win32", "win32") def test_playlist_load_ascii(self): """Test loading ascii paths from a playlist file.""" the_playlist_file = path.join(RSRC, b"playlist.m3u") m3ufile = M3UFile(the_playlist_file) m3ufile.load() assert m3ufile.media_list[0] == bytestring_path( "/This/is/a/path/to_a_file.mp3" ) @unittest.skipIf(sys.platform == "win32", "win32") def test_playlist_load_unicode(self): """Test loading unicode paths from a playlist file.""" the_playlist_file = path.join(RSRC, b"playlist.m3u8") m3ufile = M3UFile(the_playlist_file) m3ufile.load() assert m3ufile.media_list[0] == bytestring_path( "/This/is/å/path/to_a_file.mp3" ) @unittest.skipUnless(sys.platform == "win32", "win32") def test_playlist_load_unicode_windows(self): """Test loading unicode paths from a playlist file.""" the_playlist_file = path.join(RSRC, b"playlist_windows.m3u8") winpath = bytestring_path( path.join("x:\\", "This", "is", "å", "path", "to_a_file.mp3") ) m3ufile = M3UFile(the_playlist_file) m3ufile.load() assert m3ufile.media_list[0] == winpath def test_playlist_load_extm3u(self): """Test loading a playlist with an #EXTM3U header.""" the_playlist_file = path.join(RSRC, b"playlist.m3u") m3ufile = M3UFile(the_playlist_file) m3ufile.load() assert m3ufile.extm3u def test_playlist_load_non_extm3u(self): """Test loading a playlist without an #EXTM3U header.""" the_playlist_file = path.join(RSRC, b"playlist_non_ext.m3u") m3ufile = M3UFile(the_playlist_file) m3ufile.load() assert not m3ufile.extm3u ================================================ FILE: test/test_metadata_plugins.py ================================================ from collections.abc import Iterable import pytest from beets import metadata_plugins from beets.test.helper import PluginMixin class ErrorMetadataMockPlugin(metadata_plugins.MetadataSourcePlugin): """A metadata source plugin that raises errors in all its methods.""" def candidates(self, *args, **kwargs): raise ValueError("Mocked error") def item_candidates(self, *args, **kwargs): for i in range(3): raise ValueError("Mocked error") yield # This is just to make this a generator def album_for_id(self, *args, **kwargs): raise ValueError("Mocked error") def track_for_id(self, *args, **kwargs): raise ValueError("Mocked error") class TestMetadataPluginsException(PluginMixin): """Check that errors during the metadata plugins do not crash beets. They should be logged as errors instead. """ @pytest.fixture(autouse=True) def setup(self): metadata_plugins.find_metadata_source_plugins.cache_clear() metadata_plugins.get_metadata_source.cache_clear() self.register_plugin(ErrorMetadataMockPlugin) yield self.unload_plugins() @pytest.fixture def call_method(self, method_name, args): def _call(): result = getattr(metadata_plugins, method_name)(*args) return list(result) if isinstance(result, Iterable) else result return _call @pytest.mark.parametrize( "method_name,args", [ ("candidates", ()), ("item_candidates", ()), ("albums_for_ids", (["some_id"],)), ("tracks_for_ids", (["some_id"],)), ("album_for_id", ("some_id", "ErrorMetadataMock")), ("track_for_id", ("some_id", "ErrorMetadataMock")), ], ) def test_logging(self, caplog, call_method, method_name): self.config["raise_on_error"] = False call_method() assert ( f"Error in 'ErrorMetadataMock.{method_name}': Mocked error" in caplog.text ) @pytest.mark.parametrize( "method_name,args", [ ("candidates", ()), ("item_candidates", ()), ("albums_for_ids", (["some_id"],)), ("tracks_for_ids", (["some_id"],)), ("album_for_id", ("some_id", "ErrorMetadataMock")), ("track_for_id", ("some_id", "ErrorMetadataMock")), ], ) def test_raising(self, call_method): self.config["raise_on_error"] = True with pytest.raises(ValueError, match="Mocked error"): call_method() class TestSearchApiMetadataSourcePlugin(PluginMixin): plugin = "none" preload_plugin = False class RaisingSearchApiMetadataMockPlugin( metadata_plugins.SearchApiMetadataSourcePlugin[ metadata_plugins.IDResponse ] ): def get_search_query_with_filters(self, _): return "", {} def get_search_response(self, _): raise ValueError("Search failure") def album_for_id(self, _): return None def track_for_id(self, _): return None @pytest.fixture def search_plugin(self): return self.RaisingSearchApiMetadataMockPlugin() def test_search_api_returns_empty_when_raise_on_error_disabled( self, config, search_plugin, caplog ): config["raise_on_error"] = False assert search_plugin._search_api("track", "query", {}) == () assert "Search failure" in caplog.text def test_search_api_raises_when_raise_on_error_enabled( self, config, search_plugin ): config["raise_on_error"] = True with pytest.raises(ValueError, match="Search failure"): search_plugin._search_api("track", "query", {}) ================================================ FILE: test/test_metasync.py ================================================ # This file is part of beets. # Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import platform import time from datetime import datetime from beets.library import Item from beets.test import _common from beets.test.helper import IOMixin, PluginTestCase def _parsetime(s): return time.mktime(datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timetuple()) def _is_windows(): return platform.system() == "Windows" class MetaSyncTest(IOMixin, PluginTestCase): plugin = "metasync" itunes_library_unix = os.path.join(_common.RSRC, b"itunes_library_unix.xml") itunes_library_windows = os.path.join( _common.RSRC, b"itunes_library_windows.xml" ) def setUp(self): super().setUp() self.config["metasync"]["source"] = "itunes" if _is_windows(): self.config["metasync"]["itunes"]["library"] = os.fsdecode( self.itunes_library_windows ) else: self.config["metasync"]["itunes"]["library"] = os.fsdecode( self.itunes_library_unix ) self._set_up_data() def _set_up_data(self): items = [_common.item() for _ in range(2)] items[0].title = "Tessellate" items[0].artist = "alt-J" items[0].albumartist = "alt-J" items[0].album = "An Awesome Wave" items[0].itunes_rating = 60 items[1].title = "Breezeblocks" items[1].artist = "alt-J" items[1].albumartist = "alt-J" items[1].album = "An Awesome Wave" if _is_windows(): items[ 0 ].path = "G:\\Music\\Alt-J\\An Awesome Wave\\03 Tessellate.mp3" items[ 1 ].path = "G:\\Music\\Alt-J\\An Awesome Wave\\04 Breezeblocks.mp3" else: items[0].path = "/Music/Alt-J/An Awesome Wave/03 Tessellate.mp3" items[1].path = "/Music/Alt-J/An Awesome Wave/04 Breezeblocks.mp3" for item in items: self.lib.add(item) def test_load_item_types(self): # This test also verifies that the MetaSources have loaded correctly assert "amarok_score" in Item._types assert "itunes_rating" in Item._types def test_pretend_sync_from_itunes(self): out = self.run_with_output("metasync", "-p") assert "itunes_rating: 60 -> 80" in out assert "itunes_rating: 100" in out assert "itunes_playcount: 31" in out assert "itunes_skipcount: 3" in out assert "itunes_lastplayed: 2015-05-04 12:20:51" in out assert "itunes_lastskipped: 2015-02-05 15:41:04" in out assert "itunes_dateadded: 2014-04-24 09:28:38" in out assert self.lib.items()[0].itunes_rating == 60 def test_sync_from_itunes(self): self.run_command("metasync") assert self.lib.items()[0].itunes_rating == 80 assert self.lib.items()[0].itunes_playcount == 0 assert self.lib.items()[0].itunes_skipcount == 3 assert not hasattr(self.lib.items()[0], "itunes_lastplayed") assert self.lib.items()[0].itunes_lastskipped == _parsetime( "2015-02-05 15:41:04" ) assert self.lib.items()[0].itunes_dateadded == _parsetime( "2014-04-24 09:28:38" ) assert self.lib.items()[1].itunes_rating == 100 assert self.lib.items()[1].itunes_playcount == 31 assert self.lib.items()[1].itunes_skipcount == 0 assert self.lib.items()[1].itunes_lastplayed == _parsetime( "2015-05-04 12:20:51" ) assert self.lib.items()[1].itunes_dateadded == _parsetime( "2014-04-24 09:28:38" ) assert not hasattr(self.lib.items()[1], "itunes_lastskipped") ================================================ FILE: test/test_pipeline.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the "pipeline.py" restricted parallel programming library.""" import unittest import pytest from beets.util import pipeline # Some simple pipeline stages for testing. def _produce(num=5): yield from range(num) def _work(): i = None while True: i = yield i i *= 2 def _consume(result): while True: i = yield result.append(i) # Pipeline stages that raise an exception. class PipelineError(Exception): pass def _exc_produce(num=5): yield from range(num) raise PipelineError() def _exc_work(num=3): i = None while True: i = yield i if i == num: raise PipelineError() i *= 2 def _exc_consume(result, num=4): while True: i = yield if i == num: raise PipelineError() result.append(i) # A worker that yields a bubble. def _bub_work(num=3): i = None while True: i = yield i if i == num: i = pipeline.BUBBLE else: i *= 2 # Yet another worker that yields multiple messages. def _multi_work(): i = None while True: i = yield i i = pipeline.multiple([i, -i]) class SimplePipelineTest(unittest.TestCase): def setUp(self): self.result = [] self.pl = pipeline.Pipeline( (_produce(), _work(), _consume(self.result)) ) def test_run_sequential(self): self.pl.run_sequential() assert self.result == [0, 2, 4, 6, 8] def test_run_parallel(self): self.pl.run_parallel() assert self.result == [0, 2, 4, 6, 8] def test_pull(self): pl = pipeline.Pipeline((_produce(), _work())) assert list(pl.pull()) == [0, 2, 4, 6, 8] def test_pull_chain(self): pl = pipeline.Pipeline((_produce(), _work())) pl2 = pipeline.Pipeline((pl.pull(), _work())) assert list(pl2.pull()) == [0, 4, 8, 12, 16] class ParallelStageTest(unittest.TestCase): def setUp(self): self.result = [] self.pl = pipeline.Pipeline( (_produce(), (_work(), _work()), _consume(self.result)) ) def test_run_sequential(self): self.pl.run_sequential() assert self.result == [0, 2, 4, 6, 8] def test_run_parallel(self): self.pl.run_parallel() # Order possibly not preserved; use set equality. assert set(self.result) == {0, 2, 4, 6, 8} def test_pull(self): pl = pipeline.Pipeline((_produce(), (_work(), _work()))) assert list(pl.pull()) == [0, 2, 4, 6, 8] class ExceptionTest(unittest.TestCase): def setUp(self): self.result = [] def run_sequential(self, *stages): pl = pipeline.Pipeline(stages) with pytest.raises(PipelineError): pl.run_sequential() def run_parallel(self, *stages): pl = pipeline.Pipeline(stages) with pytest.raises(PipelineError): pl.run_parallel() def test_run_sequential(self): """Test that exceptions from various stages of the pipeline are properly propagated when running sequentially. """ self.run_sequential(_exc_produce(), _work(), _consume(self.result)) self.run_sequential(_produce(), _exc_work(), _consume(self.result)) self.run_sequential(_produce(), _work(), _exc_consume(self.result)) def test_run_parallel(self): """Test that exceptions from various stages of the pipeline are properly propagated when running in parallel. """ self.run_parallel(_exc_produce(), _work(), _consume(self.result)) self.run_parallel(_produce(), _exc_work(), _consume(self.result)) self.run_parallel(_produce(), _work(), _exc_consume(self.result)) def test_pull(self): pl = pipeline.Pipeline((_produce(), _exc_work())) pull = pl.pull() for i in range(3): next(pull) with pytest.raises(PipelineError): next(pull) class ParallelExceptionTest(unittest.TestCase): def setUp(self): self.result = [] self.pl = pipeline.Pipeline( (_produce(), (_exc_work(), _exc_work()), _consume(self.result)) ) def test_run_parallel(self): with pytest.raises(PipelineError): self.pl.run_parallel() class ConstrainedThreadedPipelineTest(unittest.TestCase): def setUp(self): self.result = [] def test_constrained(self): # Do a "significant" amount of work... self.pl = pipeline.Pipeline( (_produce(1000), _work(), _consume(self.result)) ) # ... with only a single queue slot. self.pl.run_parallel(1) assert self.result == [i * 2 for i in range(1000)] def test_constrained_exception(self): # Raise an exception in a constrained pipeline. self.pl = pipeline.Pipeline( (_produce(1000), _exc_work(), _consume(self.result)) ) with pytest.raises(PipelineError): self.pl.run_parallel(1) def test_constrained_parallel(self): self.pl = pipeline.Pipeline( (_produce(1000), (_work(), _work()), _consume(self.result)) ) self.pl.run_parallel(1) assert set(self.result) == {i * 2 for i in range(1000)} class BubbleTest(unittest.TestCase): def setUp(self): self.result = [] self.pl = pipeline.Pipeline( (_produce(), _bub_work(), _consume(self.result)) ) def test_run_sequential(self): self.pl.run_sequential() assert self.result == [0, 2, 4, 8] def test_run_parallel(self): self.pl.run_parallel() assert self.result == [0, 2, 4, 8] def test_pull(self): pl = pipeline.Pipeline((_produce(), _bub_work())) assert list(pl.pull()) == [0, 2, 4, 8] class MultiMessageTest(unittest.TestCase): def setUp(self): self.result = [] self.pl = pipeline.Pipeline( (_produce(), _multi_work(), _consume(self.result)) ) def test_run_sequential(self): self.pl.run_sequential() assert self.result == [0, 0, 1, -1, 2, -2, 3, -3, 4, -4] def test_run_parallel(self): self.pl.run_parallel() assert self.result == [0, 0, 1, -1, 2, -2, 3, -3, 4, -4] def test_pull(self): pl = pipeline.Pipeline((_produce(), _multi_work())) assert list(pl.pull()) == [0, 0, 1, -1, 2, -2, 3, -3, 4, -4] class StageDecoratorTest(unittest.TestCase): def test_stage_decorator(self): @pipeline.stage def add(n, i): return i + n pl = pipeline.Pipeline([iter([1, 2, 3]), add(2)]) assert list(pl.pull()) == [3, 4, 5] def test_mutator_stage_decorator(self): @pipeline.mutator_stage def setkey(key, item): item[key] = True pl = pipeline.Pipeline( [iter([{"x": False}, {"a": False}]), setkey("x")] ) assert list(pl.pull()) == [{"x": True}, {"a": False, "x": True}] ================================================ FILE: test/test_plugins.py ================================================ # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import importlib import itertools import logging import os import pkgutil import sys from typing import ClassVar from unittest.mock import ANY, Mock, patch import pytest from mediafile import MediaFile from beets import config, plugins, ui from beets.dbcore import types from beets.importer import ( Action, ArchiveImportTask, SentinelImportTask, SingletonImportTask, ) from beets.library import Item from beets.test import helper from beets.test.helper import ( AutotagStub, ImportHelper, IOMixin, PluginMixin, PluginTestCase, TerminalImportMixin, ) from beets.util import PromptChoice, displayable_path, syspath class TestPluginRegistration(IOMixin, PluginTestCase): class RatingPlugin(plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "rating": types.Float(), "multi_value": types.MULTI_VALUE_DSV, } def __init__(self): super().__init__() self.register_listener("write", self.on_write) @staticmethod def on_write(item=None, path=None, tags=None): if tags["artist"] == "XXX": tags["artist"] = "YYY" def setUp(self): super().setUp() self.register_plugin(self.RatingPlugin) def test_field_type_registered(self): assert isinstance(Item._types.get("rating"), types.Float) def test_duplicate_type(self): class DuplicateTypePlugin(plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "rating": types.INTEGER } self.register_plugin(DuplicateTypePlugin) with pytest.raises( plugins.PluginConflictError, match="already been defined" ): Item._types def test_listener_registered(self): self.RatingPlugin() item = self.add_item_fixture(artist="XXX") item.write() assert MediaFile(syspath(item.path)).artist == "YYY" def test_multi_value_flex_field_type(self): item = Item(path="apath", artist="aaa") item.multi_value = ["one", "two", "three"] item.add(self.lib) out = self.run_with_output("ls", "-f", "$multi_value") assert out == "one; two; three\n" class PluginImportTestCase(ImportHelper, PluginTestCase): def setUp(self): super().setUp() self.prepare_album_for_import(2) class EventsTest(PluginImportTestCase): def test_import_task_created(self): self.importer = self.setup_importer(pretend=True) with helper.capture_log() as logs: self.importer.run() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. assert logs.count("Sending event: import_task_created") == 1 logs = [line for line in logs if not line.startswith("Sending event:")] assert logs == [ f"Album: {displayable_path(os.path.join(self.import_dir, b'album'))}", f" {displayable_path(self.import_media[0].path)}", f" {displayable_path(self.import_media[1].path)}", ] def test_import_task_created_with_plugin(self): class ToSingletonPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "import_task_created", self.import_task_created_event ) def import_task_created_event(self, session, task): if ( isinstance(task, SingletonImportTask) or isinstance(task, SentinelImportTask) or isinstance(task, ArchiveImportTask) ): return task new_tasks = [] for item in task.items: new_tasks.append(SingletonImportTask(task.toppath, item)) return new_tasks to_singleton_plugin = ToSingletonPlugin self.register_plugin(to_singleton_plugin) self.importer = self.setup_importer(pretend=True) with helper.capture_log() as logs: self.importer.run() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. assert logs.count("Sending event: import_task_created") == 1 logs = [line for line in logs if not line.startswith("Sending event:")] assert logs == [ f"Singleton: {displayable_path(self.import_media[0].path)}", f"Singleton: {displayable_path(self.import_media[1].path)}", ] class ListenersTest(PluginTestCase): def test_register(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener("cli_exit", self.dummy) self.register_listener("cli_exit", self.dummy) def dummy(self): pass d = DummyPlugin() assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy] d2 = DummyPlugin() assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy] d.register_listener("cli_exit", d2.dummy) assert DummyPlugin._raw_listeners["cli_exit"] == [d.dummy, d2.dummy] def test_events_called(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.foo = Mock(__name__="foo") self.register_listener("event_foo", self.foo) self.bar = Mock(__name__="bar") self.register_listener("event_bar", self.bar) d = DummyPlugin() plugins.send("event") d.foo.assert_has_calls([]) d.bar.assert_has_calls([]) plugins.send("event_foo", var="tagada") d.foo.assert_called_once_with(var="tagada") d.bar.assert_has_calls([]) def test_listener_params(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() for i in itertools.count(1): try: meth = getattr(self, f"dummy{i}") except AttributeError: break self.register_listener(f"event{i}", meth) def dummy1(self, foo): assert foo == 5 def dummy2(self, foo=None): assert foo == 5 def dummy3(self): # argument cut off pass def dummy4(self, bar=None): # argument cut off pass def dummy5(self, bar): assert not True # more complex examples def dummy6(self, foo, bar=None): assert foo == 5 assert bar is None def dummy7(self, foo, **kwargs): assert foo == 5 assert kwargs == {} def dummy8(self, foo, bar, **kwargs): assert not True def dummy9(self, **kwargs): assert kwargs == {"foo": 5} DummyPlugin() plugins.send("event1", foo=5) plugins.send("event2", foo=5) plugins.send("event3", foo=5) plugins.send("event4", foo=5) with pytest.raises(TypeError): plugins.send("event5", foo=5) plugins.send("event6", foo=5) plugins.send("event7", foo=5) with pytest.raises(TypeError): plugins.send("event8", foo=5) plugins.send("event9", foo=5) class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): def setUp(self): super().setUp() self.setup_importer() self.matcher = AutotagStub(AutotagStub.IDENT).install() self.addCleanup(self.matcher.restore) # keep track of ui.input_option() calls self.input_options_patcher = patch( "beets.ui.input_options", side_effect=ui.input_options ) self.mock_input_options = self.input_options_patcher.start() def tearDown(self): super().tearDown() self.input_options_patcher.stop() def test_plugin_choices_in_ui_input_options_album(self): """Test the presence of plugin choices on the prompt (album).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.return_choices ) def return_choices(self, session, task): return [ PromptChoice("f", "Foo", None), PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ( "Apply", "More candidates", "Skip", "Use as-is", "as Tracks", "Group albums", "Enter search", "enter Id", "aBort", "Foo", "baR", ) self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with( opts, default="a", require=ANY ) def test_plugin_choices_in_ui_input_options_singleton(self): """Test the presence of plugin choices on the prompt (singleton).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.return_choices ) def return_choices(self, session, task): return [ PromptChoice("f", "Foo", None), PromptChoice("r", "baR", None), ] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ( "Apply", "More candidates", "Skip", "Use as-is", "Enter search", "enter Id", "aBort", "Foo", "baR", ) config["import"]["singletons"] = True self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_with( opts, default="a", require=ANY ) def test_choices_conflicts(self): """Test the short letter conflict solving.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.return_choices ) def return_choices(self, session, task): return [ PromptChoice("a", "A foo", None), # dupe PromptChoice("z", "baZ", None), # ok PromptChoice("z", "Zupe", None), # dupe PromptChoice("z", "Zoo", None), ] # dupe self.register_plugin(DummyPlugin) # Default options + not dupe extra choices by the plugin ('baZ') opts = ( "Apply", "More candidates", "Skip", "Use as-is", "as Tracks", "Group albums", "Enter search", "enter Id", "aBort", "baZ", ) self.importer.add_choice(Action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with( opts, default="a", require=ANY ) def test_plugin_callback(self): """Test that plugin callbacks are being called upon user choice.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.return_choices ) def return_choices(self, session, task): return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): pass self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ( "Apply", "More candidates", "Skip", "Use as-is", "as Tracks", "Group albums", "Enter search", "enter Id", "aBort", "Foo", ) # DummyPlugin.foo() should be called once with patch.object(DummyPlugin, "foo", autospec=True) as mock_foo: self.io.addinput("f") self.io.addinput("n") self.importer.run() assert mock_foo.call_count == 1 # input_options should be called twice, as foo() returns None assert self.mock_input_options.call_count == 2 self.mock_input_options.assert_called_with( opts, default="a", require=ANY ) def test_plugin_callback_return(self): """Test that plugin callbacks that return a value exit the loop.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener( "before_choose_candidate", self.return_choices ) def return_choices(self, session, task): return [PromptChoice("f", "Foo", self.foo)] def foo(self, session, task): return Action.SKIP self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ( "Apply", "More candidates", "Skip", "Use as-is", "as Tracks", "Group albums", "Enter search", "enter Id", "aBort", "Foo", ) # DummyPlugin.foo() should be called once self.io.addinput("f") self.importer.run() # input_options should be called once, as foo() returns SKIP self.mock_input_options.assert_called_once_with( opts, default="a", require=ANY ) def get_available_plugins(): """Get all available plugins in the beetsplug namespace.""" namespace_pkg = importlib.import_module("beetsplug") return [ m.name for m in pkgutil.iter_modules(namespace_pkg.__path__) if not m.name.startswith("_") ] class TestImportPlugin(PluginMixin): @pytest.fixture(params=get_available_plugins()) def plugin_name(self, request): """Fixture to provide the name of each available plugin.""" name = request.param # skip gstreamer plugins on windows gstreamer_plugins = {"bpd", "replaygain"} if sys.platform == "win32" and name in gstreamer_plugins: pytest.skip(f"GStreamer is not available on Windows: {name}") return name def unload_plugins(self): """Unimport plugins before each test to avoid conflicts.""" super().unload_plugins() for mod in list(sys.modules): if mod.startswith("beetsplug."): del sys.modules[mod] @pytest.fixture(autouse=True) def cleanup(self): """Ensure plugins are unimported before and after each test.""" self.unload_plugins() yield self.unload_plugins() @pytest.mark.skipif( os.environ.get("GITHUB_ACTIONS") != "true", reason=( "Requires all dependencies to be installed, which we can't" " guarantee in the local environment." ), ) def test_import_plugin(self, caplog, plugin_name): """Test that a plugin is importable without an error.""" caplog.set_level(logging.WARNING) self.load_plugins(plugin_name) assert "PluginImportError" not in caplog.text, ( f"Plugin '{plugin_name}' has issues during import." ) class TestDeprecationCopy: # TODO: remove this test in Beets 3.0.0 def test_legacy_metadata_plugin_deprecation(self): """Test that a MetadataSourcePlugin with 'legacy' data_source raises a deprecation warning and all function and properties are copied from the base class. """ with pytest.warns(DeprecationWarning, match="LegacyMetadataPlugin"): class LegacyMetadataPlugin(plugins.BeetsPlugin): data_source = "legacy" # Assert all methods are present assert hasattr(LegacyMetadataPlugin, "albums_for_ids") assert hasattr(LegacyMetadataPlugin, "tracks_for_ids") assert hasattr(LegacyMetadataPlugin, "data_source_mismatch_penalty") assert hasattr(LegacyMetadataPlugin, "_extract_id") assert hasattr(LegacyMetadataPlugin, "get_artist") class TestMusicBrainzPluginLoading: @pytest.fixture(autouse=True) def config(self): _config = config _config.sources = [] _config.read(user=False, defaults=True) return _config def test_default(self): assert "musicbrainz" in plugins.get_plugin_names() def test_other_plugin_enabled(self, config): config["plugins"] = ["anything"] assert "musicbrainz" not in plugins.get_plugin_names() def test_deprecated_enabled(self, config, caplog): config["plugins"] = ["anything"] config["musicbrainz"]["enabled"] = True assert "musicbrainz" in plugins.get_plugin_names() assert ( "musicbrainz.enabled' configuration option is deprecated" in caplog.text ) def test_deprecated_disabled(self, config, caplog): config["musicbrainz"]["enabled"] = False assert "musicbrainz" not in plugins.get_plugin_names() assert ( "musicbrainz.enabled' configuration option is deprecated" in caplog.text ) ================================================ FILE: test/test_query.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database.""" import sys from functools import partial from pathlib import Path import pytest from beets.dbcore import types from beets.dbcore.query import ( AndQuery, BooleanQuery, DateQuery, FalseQuery, MatchQuery, NoneQuery, NotQuery, NumericQuery, OrQuery, ParsingError, PathQuery, RegexpQuery, StringFieldQuery, StringQuery, SubstringQuery, TrueQuery, ) from beets.library import Item from beets.test import _common from beets.test.helper import TestHelper # Because the absolute path begins with something like C:, we # can't disambiguate it from an ordinary query. WIN32_NO_IMPLICIT_PATHS = "Implicit paths are not supported on Windows" _p = pytest.param @pytest.fixture(scope="class") def helper(): helper = TestHelper() helper.setup_beets() yield helper helper.teardown_beets() class TestGet: @pytest.fixture(scope="class") def lib(self, helper): album_items = [ helper.create_item( title="first", artist="one", artists=["one", "eleven"], album="baz", year=2001, comp=True, genres=["rock"], ), helper.create_item( title="second", artist="two", artists=["two", "twelve"], album="baz", year=2002, comp=True, genres=["Rock"], ), ] album = helper.lib.add_album(album_items) album.albumflex = "foo" album.store() helper.add_item( title="third", artist="three", artists=["three", "one"], album="foo", year=2003, comp=False, genres=["Hard Rock"], comments="caf\xe9", ) return helper.lib @pytest.mark.parametrize( "q, expected_titles", [ ("", ["first", "second", "third"]), (None, ["first", "second", "third"]), (":oNE", []), (":one", ["first"]), (":sec :ond", ["second"]), (":second", ["second"]), ("=rock", ["first"]), ('=~"hard rock"', ["third"]), (":t$", ["first"]), ("oNE", ["first"]), ("baz", ["first", "second"]), ("sec ond", ["second"]), ("three", ["third"]), ("albumflex:foo", ["first", "second"]), ("artist::t.+r", ["third"]), ("artist:thrEE", ["third"]), ("artists::eleven", ["first"]), ("artists::one", ["first", "third"]), ("ArTiST:three", ["third"]), ("comments:caf\xe9", ["third"]), ("comp:true", ["first", "second"]), ("comp:false", ["third"]), ("genres:=rock", ["first"]), ("genres:=Rock", ["second"]), ('genres:="Hard Rock"', ["third"]), ('genres:=~"hard rock"', ["third"]), ("genres:=~rock", ["first", "second"]), ('genres:="hard rock"', []), ("popebear", []), ("pope:bear", []), ("singleton:true", ["third"]), ("singleton:1", ["third"]), ("singleton:false", ["first", "second"]), ("singleton:0", ["first", "second"]), ("title:ond", ["second"]), ("title::sec", ["second"]), ("year:2001", ["first"]), ("year:2000..2002", ["first", "second"]), ("xyzzy:nonsense", []), ], ) def test_get_query(self, lib, q, expected_titles): assert {i.title for i in lib.items(q)} == set(expected_titles) @pytest.mark.parametrize( "q, expected_titles", [ (BooleanQuery("comp", True), ("third",)), (DateQuery("added", "2000-01-01"), ("first", "second", "third")), (FalseQuery(), ("first", "second", "third")), (MatchQuery("year", "2003"), ("first", "second")), (NoneQuery("rg_track_gain"), ()), (NumericQuery("year", "2001..2002"), ("third",)), ( AndQuery( [BooleanQuery("comp", True), NumericQuery("year", "2002")] ), ("first", "third"), ), ( OrQuery( [BooleanQuery("comp", True), NumericQuery("year", "2002")] ), ("third",), ), (RegexpQuery("artist", "^t"), ("first",)), (SubstringQuery("album", "ba"), ("third",)), (TrueQuery(), ()), ], ) def test_query_logic(self, lib, q, expected_titles): def get_results(*args): return {i.title for i in lib.items(*args)} # not(a and b) <-> not(a) or not(b) not_q = NotQuery(q) not_q_results = get_results(not_q) assert not_q_results == set(expected_titles) # assert using OrQuery, AndQuery q_or = OrQuery([q, not_q]) q_and = AndQuery([q, not_q]) assert get_results(q_or) == {"first", "second", "third"} assert get_results(q_and) == set() # assert manually checking the item titles all_titles = get_results() q_results = get_results(q) assert q_results.union(not_q_results) == all_titles assert q_results.intersection(not_q_results) == set() # round trip not_not_q = NotQuery(not_q) assert get_results(q) == get_results(not_not_q) @pytest.mark.parametrize( "q, expected_titles", [ ("-artist::t.+r", ["first", "second"]), ("-:t$", ["second", "third"]), ("sec -bar", ["second"]), ("sec -title:bar", ["second"]), ("-ond", ["first", "third"]), ("^ond", ["first", "third"]), ("^title:sec", ["first", "third"]), ("-title:sec", ["first", "third"]), ], ) def test_negation_prefix(self, lib, q, expected_titles): actual_titles = {i.title for i in lib.items(q)} assert actual_titles == set(expected_titles) @pytest.mark.parametrize( "make_q", [ partial(DateQuery, "added", "2001-01-01"), partial(MatchQuery, "artist", "one"), partial(NoneQuery, "rg_track_gain"), partial(NumericQuery, "year", "2002"), partial(StringQuery, "year", "2001"), partial(RegexpQuery, "album", "^.a"), partial(SubstringQuery, "title", "x"), ], ) def test_fast_vs_slow(self, lib, make_q): """Test that the results are the same regardless of the `fast` flag for negated `FieldQuery`s. """ q_fast = make_q(True) q_slow = make_q(False) assert list(map(dict, lib.items(q_fast))) == list( map(dict, lib.items(q_slow)) ) class TestMatch: @pytest.fixture(scope="class") def item(self): return _common.item(album="the album", disc=6, year=1, bitrate=128000) @pytest.mark.parametrize( "q, should_match", [ (RegexpQuery("album", "^the album$"), True), (RegexpQuery("album", "^album$"), False), (RegexpQuery("disc", "^6$"), True), (SubstringQuery("album", "album"), True), (SubstringQuery("album", "ablum"), False), (SubstringQuery("disc", "6"), True), (StringQuery("album", "the album"), True), (StringQuery("album", "THE ALBUM"), True), (StringQuery("album", "album"), False), (NumericQuery("year", "1"), True), (NumericQuery("year", "10"), False), (NumericQuery("bitrate", "100000..200000"), True), (NumericQuery("bitrate", "200000..300000"), False), (NumericQuery("bitrate", "100000.."), True), ], ) def test_match(self, item, q, should_match): assert q.match(item) == should_match assert not NotQuery(q).match(item) == should_match class TestPathQuery: """Tests for path-based querying functionality in the database system. Verifies that path queries correctly match items by their file paths, handling special characters, case sensitivity, parent directories, and path separator detection across different platforms. """ @pytest.fixture(scope="class") def lib(self, helper): helper.add_item(path=b"/aaa/bb/c.mp3", title="path item") helper.add_item(path=b"/x/y/z.mp3", title="another item") helper.add_item(path=b"/c/_/title.mp3", title="with underscore") helper.add_item(path=b"/c/%/title.mp3", title="with percent") helper.add_item(path=rb"/c/\x/title.mp3", title="with backslash") helper.add_item(path=b"/A/B/C2.mp3", title="caps path") return helper.lib @pytest.mark.parametrize( "q, expected_titles", [ _p("path:/aaa/bb/c.mp3", ["path item"], id="exact-match"), _p("path:/aaa", ["path item"], id="parent-dir-no-slash"), _p("path:/aaa/", ["path item"], id="parent-dir-with-slash"), _p("path:/aa", [], id="no-match-does-not-match-parent-dir"), _p("path:/xyzzy/", [], id="no-match"), _p("path:/b/", [], id="fragment-no-match"), _p("path:/x/../aaa/bb", ["path item"], id="non-normalized"), _p("path::c\\.mp3$", ["path item"], id="regex"), _p("path:/c/_", ["with underscore"], id="underscore-escaped"), _p("path:/c/%", ["with percent"], id="percent-escaped"), _p("path:/c/\\\\x", ["with backslash"], id="backslash-escaped"), ], ) def test_explicit(self, monkeypatch, lib, q, expected_titles): """Test explicit path queries with different path specifications.""" monkeypatch.setattr("beets.util.case_sensitive", lambda *_: True) assert {i.title for i in lib.items(q)} == set(expected_titles) @pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS) @pytest.mark.parametrize( "q, expected_titles", [ _p("/aaa/bb", ["path item"], id="slashed-query"), _p("/aaa/bb , /aaa", ["path item"], id="path-in-or-query"), _p("c.mp3", [], id="no-slash-no-match"), _p("title:/a/b", [], id="slash-with-explicit-field-no-match"), ], ) def test_implicit(self, monkeypatch, lib, q, expected_titles): """Test implicit path detection when queries contain path separators.""" monkeypatch.setattr( "beets.dbcore.query.PathQuery.is_path_query", lambda path: True ) assert {i.title for i in lib.items(q)} == set(expected_titles) @pytest.mark.parametrize( "case_sensitive, expected_titles", [ _p(True, [], id="non-caps-dont-match-caps"), _p(False, ["caps path"], id="non-caps-match-caps"), ], ) def test_case_sensitivity( self, lib, monkeypatch, case_sensitive, expected_titles ): """Test path matching with different case sensitivity settings.""" q = "path:/a/b/c2.mp3" monkeypatch.setattr( "beets.util.case_sensitive", lambda *_: case_sensitive ) assert {i.title for i in lib.items(q)} == set(expected_titles) # FIXME: Also create a variant of this test for windows, which tests # both os.sep and os.altsep @pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS) @pytest.mark.parametrize( "q, is_path_query", [ ("/foo/bar", True), ("foo/bar", True), ("foo/", True), ("foo", False), ("foo/:bar", True), ("foo:bar/", False), ("foo:/bar", False), ], ) def test_path_sep_detection(self, monkeypatch, tmp_path, q, is_path_query): """Test detection of path queries based on the presence of path separators.""" monkeypatch.chdir(tmp_path) (tmp_path / "foo").mkdir() (tmp_path / "foo" / "bar").touch() if Path(q).is_absolute(): q = str(tmp_path / q[1:]) assert PathQuery.is_path_query(q) == is_path_query class TestQuery: ALBUM = "album title" SINGLE = "singleton" @pytest.fixture(scope="class") def lib(self, helper): helper.add_album( title=self.ALBUM, comp=True, flexbool=True, bpm=120, flexint=2, rg_track_gain=0, ) helper.add_item( title=self.SINGLE, comp=False, flexbool=False, rg_track_gain=None ) with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr( Item, "_types", {"flexbool": types.Boolean(), "flexint": types.Integer()}, ) yield helper.lib @pytest.mark.parametrize("query_class", [MatchQuery, StringFieldQuery]) def test_equality(self, query_class): assert query_class("foo", "bar") == query_class("foo", "bar") @pytest.mark.parametrize( "make_q, expected_msg", [ (lambda: NumericQuery("year", "199a"), "not an int"), (lambda: RegexpQuery("year", "199("), r"not a regular expression.*unterminated subpattern"), # noqa: E501 ] ) # fmt: skip def test_invalid_query(self, make_q, expected_msg): with pytest.raises(ParsingError, match=expected_msg): make_q() @pytest.mark.parametrize( "q, expected_titles", [ # Boolean value _p("comp:true", {ALBUM}, id="parse-true"), _p("flexbool:true", {ALBUM}, id="flex-parse-true"), _p("flexbool:false", {SINGLE}, id="flex-parse-false"), _p("flexbool:1", {ALBUM}, id="flex-parse-1"), _p("flexbool:0", {SINGLE}, id="flex-parse-0"), # TODO: shouldn't this match 1 / true instead? _p("flexbool:something", {SINGLE}, id="flex-parse-true"), # Integer value _p("bpm:120", {ALBUM}, id="int-exact-value"), _p("bpm:110..125", {ALBUM}, id="int-range"), _p("flexint:2", {ALBUM}, id="int-flex"), _p("flexint:3", set(), id="int-no-match"), _p("bpm:12", set(), id="int-dont-match-substring"), # None value _p(NoneQuery("album_id"), {SINGLE}, id="none-match-singleton"), _p(NoneQuery("rg_track_gain"), {SINGLE}, id="none-value"), ], ) def test_value_type(self, lib, q, expected_titles): assert {i.title for i in lib.items(q)} == expected_titles class TestDefaultSearchFields: @pytest.fixture(scope="class") def lib(self, helper): helper.add_album( title="title", album="album", albumartist="albumartist", catalognum="catalognum", year=2001, ) return helper.lib @pytest.mark.parametrize( "entity, q, should_match", [ _p("albums", "album", True, id="album-match-album"), _p("albums", "albumartist", True, id="album-match-albumartist"), _p("albums", "catalognum", False, id="album-dont-match-catalognum"), _p("items", "title", True, id="item-match-title"), _p("items", "2001", False, id="item-dont-match-year"), ], ) def test_search(self, lib, entity, q, should_match): assert bool(getattr(lib, entity)(q)) == should_match class TestRelatedQueries: """Test album-level queries with track-level filters and vice-versa.""" @pytest.fixture(scope="class") def lib(self, helper): for album_idx in range(1, 3): album_name = f"Album{album_idx}" items = [ helper.create_item( album=album_name, title=f"{album_name} Item{idx}" ) for idx in range(1, 3) ] album = helper.lib.add_album(items) album.artpath = f"{album_name} Artpath" album.catalognum = "ABC" album.store() return helper.lib @pytest.mark.parametrize( "q, expected_titles, expected_albums", [ _p( "title:Album1", ["Album1 Item1", "Album1 Item2"], ["Album1"], id="match-album-with-item-field-query", ), _p( "title:Item2", ["Album1 Item2", "Album2 Item2"], ["Album1", "Album2"], id="match-albums-with-item-field-query", ), _p( "artpath::Album1", ["Album1 Item1", "Album1 Item2"], ["Album1"], id="match-items-with-album-field-query", ), _p( "catalognum:ABC Album1", ["Album1 Item1", "Album1 Item2"], ["Album1"], id="query-field-common-to-album-and-item", ), ], ) def test_related_query(self, lib, q, expected_titles, expected_albums): assert {i.album for i in lib.albums(q)} == set(expected_albums) assert {i.title for i in lib.items(q)} == set(expected_titles) ================================================ FILE: test/test_release.py ================================================ """Tests for the release utils.""" import os import shutil import sys import pytest release = pytest.importorskip("extra.release") pytestmark = pytest.mark.skipif( not ( (os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform != "win32") or bool(shutil.which("pandoc")) ), reason="pandoc isn't available", ) @pytest.fixture def rst_changelog(): return """ Unreleased ---------- New features ~~~~~~~~~~~~ - :doc:`/plugins/substitute`: Some substitute multi-line change. :bug:`5467` - :ref:`list-cmd` Update. - |BeetsPlugin| Some plugin change. - See :class:`~beetsplug._utils.musicbrainz.MusicBrainzAPI` for documentation. You can do something with this command: :: $ do-something Bug fixes ~~~~~~~~~ - Some fix that refers to an issue. :bug:`5467` - Some fix that mentions user :user:`username`. - Some fix thanks to :user:`username`. :bug:`5467` - Some fix with its own bullet points using incorrect indentation: - First nested bullet point with some text that wraps to the next line - Second nested bullet point - Another fix with an enumerated list 1. First and some details 2. Second and some details Long parapgraph naaaaaaaaaaaaaaaaaaaaaaaammmmmmmmmmmmmmmmeeeeeeeeeeeeeee ending with a colon: .. For plugin developers .. ~~~~~~~~~~~~~~~~~~~~~ Other changes ~~~~~~~~~~~~~ - Changed ``bitesize`` label to ``good first issue``. Our `contribute`_ page is now automatically populated with these issues. :bug:`4855` .. _contribute: https://github.com/beetbox/beets/contribute 2.1.0 (November 22, 2024) ------------------------- Bug fixes ~~~~~~~~~ - Fixed something.""" @pytest.fixture def md_changelog(): return r"""# Unreleased ## New features - [beets.plugins.BeetsPlugin](https://beets.readthedocs.io/en/stable/api/generated/beets.plugins.BeetsPlugin.html#beets.plugins.BeetsPlugin) Some plugin change. - [list command](https://beets.readthedocs.io/en/stable/reference/cli.html#list-cmd) Update. - [Substitute Plugin](https://beets.readthedocs.io/en/stable/plugins/substitute.html): Some substitute multi-line change. :bug: (#5467) - See [beetsplug.\_utils.musicbrainz.MusicBrainzAPI](https://beets.readthedocs.io/en/stable/api/generated/beetsplug._utils.musicbrainz.MusicBrainzAPI.html#beetsplug._utils.musicbrainz.MusicBrainzAPI) for documentation. You can do something with this command: $ do-something ## Bug fixes - Another fix with an enumerated list 1. First and some details 2. Second and some details - Some fix thanks to @username. :bug: (#5467) - Some fix that mentions user @username. - Some fix that refers to an issue. :bug: (#5467) - Some fix with its own bullet points using incorrect indentation: - First nested bullet point with some text that wraps to the next line - Second nested bullet point Long parapgraph naaaaaaaaaaaaaaaaaaaaaaaammmmmmmmmmmmmmmmeeeeeeeeeeeeeee ending with a colon: ## Other changes - Changed `bitesize` label to `good first issue`. Our [contribute](https://github.com/beetbox/beets/contribute) page is now automatically populated with these issues. :bug: (#4855) # 2.1.0 (November 22, 2024) ## Bug fixes - Fixed something.""" # noqa: E501 def test_convert_rst_to_md(rst_changelog, md_changelog): actual = release.changelog_as_markdown(rst_changelog) assert actual == md_changelog ================================================ FILE: test/test_sort.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database.""" from unittest.mock import patch import beets.library from beets import config, dbcore from beets.dbcore import types from beets.library import Album from beets.test import _common from beets.test.helper import BeetsTestCase # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(BeetsTestCase): def setUp(self): super().setUp() albums = [ Album( album="Album A", genres=["Rock"], year=2001, flex1="Flex1-1", flex2="Flex2-A", albumartist="Foo", ), Album( album="Album B", genres=["Rock"], year=2001, flex1="Flex1-2", flex2="Flex2-A", albumartist="Bar", ), Album( album="Album C", genres=["Jazz"], year=2005, flex1="Flex1-1", flex2="Flex2-B", albumartist="Baz", ), ] for album in albums: self.lib.add(album) items = [_common.item() for _ in range(4)] items[0].title = "Foo bar" items[0].artist = "One" items[0].album = "Baz" items[0].year = 2001 items[0].comp = True items[0].flex1 = "Flex1-0" items[0].flex2 = "Flex2-A" items[0].album_id = albums[0].id items[0].artist_sort = None items[0].path = "/path0.mp3" items[0].track = 1 items[1].title = "Baz qux" items[1].artist = "Two" items[1].album = "Baz" items[1].year = 2002 items[1].comp = True items[1].flex1 = "Flex1-1" items[1].flex2 = "Flex2-A" items[1].album_id = albums[0].id items[1].artist_sort = None items[1].path = "/patH1.mp3" items[1].track = 2 items[2].title = "Beets 4 eva" items[2].artist = "Three" items[2].album = "Foo" items[2].year = 2003 items[2].comp = False items[2].flex1 = "Flex1-2" items[2].flex2 = "Flex1-B" items[2].album_id = albums[1].id items[2].artist_sort = None items[2].path = "/paTH2.mp3" items[2].track = 3 items[3].title = "Beets 4 eva" items[3].artist = "Three" items[3].album = "Foo2" items[3].year = 2004 items[3].comp = False items[3].flex1 = "Flex1-2" items[3].flex2 = "Flex1-C" items[3].album_id = albums[2].id items[3].artist_sort = None items[3].path = "/PATH3.mp3" items[3].track = 4 for item in items: self.lib.add(item) class SortFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = "" sort = dbcore.query.FixedFieldSort("year", True) results = self.lib.items(q, sort) assert results[0]["year"] <= results[1]["year"] assert results[0]["year"] == 2001 # same thing with query string q = "year+" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_desc(self): q = "" sort = dbcore.query.FixedFieldSort("year", False) results = self.lib.items(q, sort) assert results[0]["year"] >= results[1]["year"] assert results[0]["year"] == 2004 # same thing with query string q = "year-" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_two_field_asc(self): q = "" s1 = dbcore.query.FixedFieldSort("album", True) s2 = dbcore.query.FixedFieldSort("year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) assert results[0]["album"] <= results[1]["album"] assert results[1]["album"] <= results[2]["album"] assert results[0]["album"] == "Baz" assert results[1]["album"] == "Baz" assert results[0]["year"] <= results[1]["year"] # same thing with query string q = "album+ year+" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_path_field(self): q = "" sort = dbcore.query.FixedFieldSort("path", True) results = self.lib.items(q, sort) assert results[0]["path"] == b"/path0.mp3" assert results[1]["path"] == b"/patH1.mp3" assert results[2]["path"] == b"/paTH2.mp3" assert results[3]["path"] == b"/PATH3.mp3" class SortFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = "" sort = dbcore.query.SlowFieldSort("flex1", True) results = self.lib.items(q, sort) assert results[0]["flex1"] <= results[1]["flex1"] assert results[0]["flex1"] == "Flex1-0" # same thing with query string q = "flex1+" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_desc(self): q = "" sort = dbcore.query.SlowFieldSort("flex1", False) results = self.lib.items(q, sort) assert results[0]["flex1"] >= results[1]["flex1"] assert results[1]["flex1"] >= results[2]["flex1"] assert results[2]["flex1"] >= results[3]["flex1"] assert results[0]["flex1"] == "Flex1-2" # same thing with query string q = "flex1-" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_two_field(self): q = "" s1 = dbcore.query.SlowFieldSort("flex2", False) s2 = dbcore.query.SlowFieldSort("flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) assert results[0]["flex2"] >= results[1]["flex2"] assert results[1]["flex2"] >= results[2]["flex2"] assert results[0]["flex2"] == "Flex2-A" assert results[1]["flex2"] == "Flex2-A" assert results[0]["flex1"] <= results[1]["flex1"] # same thing with query string q = "flex2- flex1+" results2 = self.lib.items(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id class SortAlbumFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = "" sort = dbcore.query.FixedFieldSort("year", True) results = self.lib.albums(q, sort) assert results[0]["year"] <= results[1]["year"] assert results[0]["year"] == 2001 # same thing with query string q = "year+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_desc(self): q = "" sort = dbcore.query.FixedFieldSort("year", False) results = self.lib.albums(q, sort) assert results[0]["year"] >= results[1]["year"] assert results[0]["year"] == 2005 # same thing with query string q = "year-" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_two_field_asc(self): q = "" s1 = dbcore.query.FixedFieldSort("genres", True) s2 = dbcore.query.FixedFieldSort("album", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) assert results[0]["genres"] <= results[1]["genres"] assert results[1]["genres"] <= results[2]["genres"] assert results[1]["genres"] == ["Rock"] assert results[2]["genres"] == ["Rock"] assert results[1]["album"] <= results[2]["album"] # same thing with query string q = "genres+ album+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id class SortAlbumFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = "" sort = dbcore.query.SlowFieldSort("flex1", True) results = self.lib.albums(q, sort) assert results[0]["flex1"] <= results[1]["flex1"] assert results[1]["flex1"] <= results[2]["flex1"] # same thing with query string q = "flex1+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_desc(self): q = "" sort = dbcore.query.SlowFieldSort("flex1", False) results = self.lib.albums(q, sort) assert results[0]["flex1"] >= results[1]["flex1"] assert results[1]["flex1"] >= results[2]["flex1"] # same thing with query string q = "flex1-" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_two_field_asc(self): q = "" s1 = dbcore.query.SlowFieldSort("flex2", True) s2 = dbcore.query.SlowFieldSort("flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) assert results[0]["flex2"] <= results[1]["flex2"] assert results[1]["flex2"] <= results[2]["flex2"] assert results[0]["flex2"] == "Flex2-A" assert results[1]["flex2"] == "Flex2-A" assert results[0]["flex1"] <= results[1]["flex1"] # same thing with query string q = "flex2+ flex1+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id class SortAlbumComputedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = "" sort = dbcore.query.SlowFieldSort("path", True) results = self.lib.albums(q, sort) assert results[0]["path"] <= results[1]["path"] assert results[1]["path"] <= results[2]["path"] # same thing with query string q = "path+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_sort_desc(self): q = "" sort = dbcore.query.SlowFieldSort("path", False) results = self.lib.albums(q, sort) assert results[0]["path"] >= results[1]["path"] assert results[1]["path"] >= results[2]["path"] # same thing with query string q = "path-" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id class SortCombinedFieldTest(DummyDataTestCase): def test_computed_first(self): q = "" s1 = dbcore.query.SlowFieldSort("path", True) s2 = dbcore.query.FixedFieldSort("year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) assert results[0]["path"] <= results[1]["path"] assert results[1]["path"] <= results[2]["path"] q = "path+ year+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id def test_computed_second(self): q = "" s1 = dbcore.query.FixedFieldSort("year", True) s2 = dbcore.query.SlowFieldSort("path", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) assert results[0]["year"] <= results[1]["year"] assert results[1]["year"] <= results[2]["year"] assert results[0]["path"] <= results[1]["path"] q = "year+ path+" results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): assert r1.id == r2.id class ConfigSortTest(DummyDataTestCase): def test_default_sort_item(self): results = list(self.lib.items()) assert results[0].artist < results[1].artist def test_config_opposite_sort_item(self): config["sort_item"] = "artist-" results = list(self.lib.items()) assert results[0].artist > results[1].artist def test_default_sort_album(self): results = list(self.lib.albums()) assert results[0].albumartist < results[1].albumartist def test_config_opposite_sort_album(self): config["sort_album"] = "albumartist-" results = list(self.lib.albums()) assert results[0].albumartist > results[1].albumartist class CaseSensitivityTest(DummyDataTestCase): """If case_insensitive is false, lower-case values should be placed after all upper-case values. E.g., `Foo Qux bar` """ def setUp(self): super().setUp() album = Album( album="album", genres=["alternative"], year="2001", flex1="flex1", flex2="flex2-A", albumartist="bar", ) self.lib.add(album) item = _common.item() item.title = "another" item.artist = "lowercase" item.album = "album" item.year = 2001 item.comp = True item.flex1 = "flex1" item.flex2 = "flex2-A" item.album_id = album.id item.artist_sort = None item.track = 10 self.lib.add(item) self.new_album = album self.new_item = item def tearDown(self): self.new_item.remove(delete=True) self.new_album.remove(delete=True) super().tearDown() def test_smart_artist_case_insensitive(self): config["sort_case_insensitive"] = True q = "artist+" results = list(self.lib.items(q)) assert results[0].artist == "lowercase" assert results[1].artist == "One" def test_smart_artist_case_sensitive(self): config["sort_case_insensitive"] = False q = "artist+" results = list(self.lib.items(q)) assert results[0].artist == "One" assert results[-1].artist == "lowercase" def test_fixed_field_case_insensitive(self): config["sort_case_insensitive"] = True q = "album+" results = list(self.lib.albums(q)) assert results[0].album == "album" assert results[1].album == "Album A" def test_fixed_field_case_sensitive(self): config["sort_case_insensitive"] = False q = "album+" results = list(self.lib.albums(q)) assert results[0].album == "Album A" assert results[-1].album == "album" def test_flex_field_case_insensitive(self): config["sort_case_insensitive"] = True q = "flex1+" results = list(self.lib.items(q)) assert results[0].flex1 == "flex1" assert results[1].flex1 == "Flex1-0" def test_flex_field_case_sensitive(self): config["sort_case_insensitive"] = False q = "flex1+" results = list(self.lib.items(q)) assert results[0].flex1 == "Flex1-0" assert results[-1].flex1 == "flex1" def test_case_sensitive_only_affects_text(self): config["sort_case_insensitive"] = True q = "track+" results = list(self.lib.items(q)) # If the numerical values were sorted as strings, # then ['1', '10', '2'] would be valid. # print([r.track for r in results]) assert results[0].track == 1 assert results[1].track == 2 assert results[-1].track == 10 class NonExistingFieldTest(DummyDataTestCase): """Test sorting by non-existing fields""" def test_non_existing_fields_not_fail(self): qs = ["foo+", "foo-", "--", "-+", "+-", "++", "-foo-", "-foo+", "---"] q0 = "foo+" results0 = list(self.lib.items(q0)) for q1 in qs: results1 = list(self.lib.items(q1)) for r1, r2 in zip(results0, results1): assert r1.id == r2.id def test_combined_non_existing_field_asc(self): all_results = list(self.lib.items("id+")) q = "foo+ id+" results = list(self.lib.items(q)) assert len(all_results) == len(results) for r1, r2 in zip(all_results, results): assert r1.id == r2.id def test_combined_non_existing_field_desc(self): all_results = list(self.lib.items("id+")) q = "foo- id+" results = list(self.lib.items(q)) assert len(all_results) == len(results) for r1, r2 in zip(all_results, results): assert r1.id == r2.id def test_field_present_in_some_items(self): """Test ordering by a (string) field not present on all items.""" # append 'foo' to two items (1,2) lower_foo_item, higher_foo_item, *items_without_foo = self.lib.items( "id+" ) lower_foo_item.foo, higher_foo_item.foo = "bar1", "bar2" lower_foo_item.store() higher_foo_item.store() results_asc = list(self.lib.items("foo+ id+")) assert [i.id for i in results_asc] == [ # items without field first *[i.id for i in items_without_foo], lower_foo_item.id, higher_foo_item.id, ] results_desc = list(self.lib.items("foo- id+")) assert [i.id for i in results_desc] == [ higher_foo_item.id, lower_foo_item.id, # items without field last *[i.id for i in items_without_foo], ] @patch("beets.library.Item._types", {"myint": types.Integer()}) def test_int_field_present_in_some_items(self): """Test ordering by an int-type field not present on all items.""" # append int-valued 'myint' to two items (1,2) lower_myint_item, higher_myint_item, *items_without_myint = ( self.lib.items("id+") ) lower_myint_item.myint, higher_myint_item.myint = 1, 2 lower_myint_item.store() higher_myint_item.store() results_asc = list(self.lib.items("myint+ id+")) assert [i.id for i in results_asc] == [ # items without field first *[i.id for i in items_without_myint], lower_myint_item.id, higher_myint_item.id, ] results_desc = list(self.lib.items("myint- id+")) assert [i.id for i in results_desc] == [ higher_myint_item.id, lower_myint_item.id, # items without field last *[i.id for i in items_without_myint], ] def test_negation_interaction(self): """Test the handling of negation and sorting together. If a string ends with a sorting suffix, it takes precedence over the NotQuery parsing. """ query, sort = beets.library.parse_query_string( "-bar+", beets.library.Item ) assert len(query.subqueries) == 1 assert isinstance(query.subqueries[0], dbcore.query.TrueQuery) assert isinstance(sort, dbcore.query.SlowFieldSort) assert sort.field == "-bar" ================================================ FILE: test/test_template.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for template engine.""" import unittest from beets.util import functemplate def _normexpr(expr): """Normalize an Expression object's parts, collapsing multiple adjacent text blocks and removing empty text blocks. Generates a sequence of parts. """ textbuf = [] for part in expr.parts: if isinstance(part, str): textbuf.append(part) else: if textbuf: text = "".join(textbuf) if text: yield text textbuf = [] yield part if textbuf: text = "".join(textbuf) if text: yield text def _normparse(text): """Parse a template and then normalize the resulting Expression.""" return _normexpr(functemplate._parse(text)) class ParseTest(unittest.TestCase): def test_empty_string(self): assert list(_normparse("")) == [] def _assert_symbol(self, obj, ident): """Assert that an object is a Symbol with the given identifier.""" assert isinstance(obj, functemplate.Symbol), f"not a Symbol: {obj}" assert obj.ident == ident, f"wrong identifier: {obj.ident} vs. {ident}" def _assert_call(self, obj, ident, numargs): """Assert that an object is a Call with the given identifier and argument count. """ assert isinstance(obj, functemplate.Call), f"not a Call: {obj}" assert obj.ident == ident, f"wrong identifier: {obj.ident} vs. {ident}" assert len(obj.args) == numargs, ( f"wrong argument count in {obj.ident}: {len(obj.args)} vs. {numargs}" ) def test_plain_text(self): assert list(_normparse("hello world")) == ["hello world"] def test_escaped_character_only(self): assert list(_normparse("$$")) == ["$"] def test_escaped_character_in_text(self): assert list(_normparse("a $$ b")) == ["a $ b"] def test_escaped_character_at_start(self): assert list(_normparse("$$ hello")) == ["$ hello"] def test_escaped_character_at_end(self): assert list(_normparse("hello $$")) == ["hello $"] def test_escaped_function_delim(self): assert list(_normparse("a $% b")) == ["a % b"] def test_escaped_sep(self): assert list(_normparse("a $, b")) == ["a , b"] def test_escaped_close_brace(self): assert list(_normparse("a $} b")) == ["a } b"] def test_bare_value_delim_kept_intact(self): assert list(_normparse("a $ b")) == ["a $ b"] def test_bare_function_delim_kept_intact(self): assert list(_normparse("a % b")) == ["a % b"] def test_bare_opener_kept_intact(self): assert list(_normparse("a { b")) == ["a { b"] def test_bare_closer_kept_intact(self): assert list(_normparse("a } b")) == ["a } b"] def test_bare_sep_kept_intact(self): assert list(_normparse("a , b")) == ["a , b"] def test_symbol_alone(self): parts = list(_normparse("$foo")) assert len(parts) == 1 self._assert_symbol(parts[0], "foo") def test_symbol_in_text(self): parts = list(_normparse("hello $foo world")) assert len(parts) == 3 assert parts[0] == "hello " self._assert_symbol(parts[1], "foo") assert parts[2] == " world" def test_symbol_with_braces(self): parts = list(_normparse("hello${foo}world")) assert len(parts) == 3 assert parts[0] == "hello" self._assert_symbol(parts[1], "foo") assert parts[2] == "world" def test_unclosed_braces_symbol(self): assert list(_normparse("a ${ b")) == ["a ${ b"] def test_empty_braces_symbol(self): assert list(_normparse("a ${} b")) == ["a ${} b"] def test_call_without_args_at_end(self): assert list(_normparse("foo %bar")) == ["foo %bar"] def test_call_without_args(self): assert list(_normparse("foo %bar baz")) == ["foo %bar baz"] def test_call_with_unclosed_args(self): assert list(_normparse("foo %bar{ baz")) == ["foo %bar{ baz"] def test_call_with_unclosed_multiple_args(self): assert list(_normparse("foo %bar{bar,bar baz")) == [ "foo %bar{bar,bar baz" ] def test_call_empty_arg(self): parts = list(_normparse("%foo{}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 1) assert list(_normexpr(parts[0].args[0])) == [] def test_call_single_arg(self): parts = list(_normparse("%foo{bar}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 1) assert list(_normexpr(parts[0].args[0])) == ["bar"] def test_call_two_args(self): parts = list(_normparse("%foo{bar,baz}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 2) assert list(_normexpr(parts[0].args[0])) == ["bar"] assert list(_normexpr(parts[0].args[1])) == ["baz"] def test_call_with_escaped_sep(self): parts = list(_normparse("%foo{bar$,baz}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 1) assert list(_normexpr(parts[0].args[0])) == ["bar,baz"] def test_call_with_escaped_close(self): parts = list(_normparse("%foo{bar$}baz}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 1) assert list(_normexpr(parts[0].args[0])) == ["bar}baz"] def test_call_with_symbol_argument(self): parts = list(_normparse("%foo{$bar,baz}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) assert len(arg_parts) == 1 self._assert_symbol(arg_parts[0], "bar") assert list(_normexpr(parts[0].args[1])) == ["baz"] def test_call_with_nested_call_argument(self): parts = list(_normparse("%foo{%bar{},baz}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) assert len(arg_parts) == 1 self._assert_call(arg_parts[0], "bar", 1) assert list(_normexpr(parts[0].args[1])) == ["baz"] def test_nested_call_with_argument(self): parts = list(_normparse("%foo{%bar{baz}}")) assert len(parts) == 1 self._assert_call(parts[0], "foo", 1) arg_parts = list(_normexpr(parts[0].args[0])) assert len(arg_parts) == 1 self._assert_call(arg_parts[0], "bar", 1) assert list(_normexpr(arg_parts[0].args[0])) == ["baz"] def test_sep_before_call_two_args(self): parts = list(_normparse("hello, %foo{bar,baz}")) assert len(parts) == 2 assert parts[0] == "hello, " self._assert_call(parts[1], "foo", 2) assert list(_normexpr(parts[1].args[0])) == ["bar"] assert list(_normexpr(parts[1].args[1])) == ["baz"] def test_sep_with_symbols(self): parts = list(_normparse("hello,$foo,$bar")) assert len(parts) == 4 assert parts[0] == "hello," self._assert_symbol(parts[1], "foo") assert parts[2] == "," self._assert_symbol(parts[3], "bar") def test_newline_at_end(self): parts = list(_normparse("foo\n")) assert len(parts) == 1 assert parts[0] == "foo\n" class EvalTest(unittest.TestCase): def _eval(self, template): values = { "foo": "bar", "baz": "BaR", } functions = { "lower": str.lower, "len": len, } return functemplate.Template(template).substitute(values, functions) def test_plain_text(self): assert self._eval("foo") == "foo" def test_subtitute_value(self): assert self._eval("$foo") == "bar" def test_subtitute_value_in_text(self): assert self._eval("hello $foo world") == "hello bar world" def test_not_subtitute_undefined_value(self): assert self._eval("$bar") == "$bar" def test_function_call(self): assert self._eval("%lower{FOO}") == "foo" def test_function_call_with_text(self): assert self._eval("A %lower{FOO} B") == "A foo B" def test_nested_function_call(self): assert self._eval("%lower{%lower{FOO}}") == "foo" def test_symbol_in_argument(self): assert self._eval("%lower{$baz}") == "bar" def test_function_call_exception(self): res = self._eval("%lower{a,b,c,d,e}") assert isinstance(res, str) def test_function_returning_integer(self): assert self._eval("%len{foo}") == "3" def test_not_subtitute_undefined_func(self): assert self._eval("%bar{}") == "%bar{}" def test_not_subtitute_func_with_no_args(self): assert self._eval("%lower") == "%lower" def test_function_call_with_empty_arg(self): assert self._eval("%len{}") == "0" ================================================ FILE: test/test_types.py ================================================ import time import beets from beets.dbcore import types from beets.util import normpath def test_datetype(): t = types.DATE # format time_format = beets.config["time_format"].as_str() time_local = time.strftime(time_format, time.localtime(123456789)) assert time_local == t.format(123456789) # parse assert 123456789.0 == t.parse(time_local) assert 123456789.0 == t.parse("123456789.0") assert t.null == t.parse("not123456789.0") assert t.null == t.parse("1973-11-29") def test_pathtype(): t = types.PathType() # format assert "/tmp" == t.format("/tmp") assert "/tmp/\xe4lbum" == t.format("/tmp/\u00e4lbum") # parse assert normpath(b"/tmp") == t.parse("/tmp") assert normpath(b"/tmp/\xc3\xa4lbum") == t.parse("/tmp/\u00e4lbum/") def test_musicalkey(): t = types.MusicalKey() # parse assert "C#m" == t.parse("c#m") assert "Gm" == t.parse("g minor") assert "Not c#m" == t.parse("not C#m") def test_durationtype(): t = types.DurationType() # format assert "1:01" == t.format(61.23) assert "60:01" == t.format(3601.23) assert "0:00" == t.format(None) # parse assert 61.0 == t.parse("1:01") assert 61.23 == t.parse("61.23") assert 3601.0 == t.parse("60:01") assert t.null == t.parse("1:00:01") assert t.null == t.parse("not61.23") # config format_raw_length beets.config["format_raw_length"] = True assert 61.23 == t.format(61.23) assert 3601.23 == t.format(3601.23) ================================================ FILE: test/test_util.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for base utils from the beets.util package.""" import os import platform import re import subprocess import sys import unittest from unittest.mock import Mock, patch import pytest from beets import util from beets.library import Item from beets.test import _common class UtilTest(unittest.TestCase): def test_open_anything(self): with _common.system_mock("Windows"): assert util.open_anything() == 'cmd /c start ""' with _common.system_mock("Darwin"): assert util.open_anything() == "open" with _common.system_mock("Tagada"): assert util.open_anything() == "xdg-open" @patch("os.execlp") @patch("beets.util.open_anything") def test_interactive_open(self, mock_open, mock_execlp): mock_open.return_value = "tagada" util.interactive_open(["foo"], util.open_anything()) mock_execlp.assert_called_once_with("tagada", "tagada", "foo") mock_execlp.reset_mock() util.interactive_open(["foo"], "bar") mock_execlp.assert_called_once_with("bar", "bar", "foo") def test_sanitize_unix_replaces_leading_dot(self): with _common.platform_posix(): p = util.sanitize_path("one/.two/three") assert "." not in p def test_sanitize_windows_replaces_trailing_dot(self): with _common.platform_windows(): p = util.sanitize_path("one/two./three") assert "." not in p def test_sanitize_windows_replaces_illegal_chars(self): with _common.platform_windows(): p = util.sanitize_path(':*?"<>|') assert ":" not in p assert "*" not in p assert "?" not in p assert '"' not in p assert "<" not in p assert ">" not in p assert "|" not in p def test_sanitize_windows_replaces_trailing_space(self): with _common.platform_windows(): p = util.sanitize_path("one/two /three") assert " " not in p def test_sanitize_path_works_on_empty_string(self): with _common.platform_posix(): p = util.sanitize_path("") assert p == "" def test_sanitize_with_custom_replace_overrides_built_in_sub(self): with _common.platform_posix(): p = util.sanitize_path("a/.?/b", [(re.compile(r"foo"), "bar")]) assert p == "a/.?/b" def test_sanitize_with_custom_replace_adds_replacements(self): with _common.platform_posix(): p = util.sanitize_path("foo/bar", [(re.compile(r"foo"), "bar")]) assert p == "bar/bar" @unittest.skip("unimplemented: #359") def test_sanitize_empty_component(self): with _common.platform_posix(): p = util.sanitize_path("foo//bar", [(re.compile(r"^$"), "_")]) assert p == "foo/_/bar" @patch("beets.util.subprocess.Popen") def test_command_output(self, mock_popen): def popen_fail(*args, **kwargs): m = Mock(returncode=1) m.communicate.return_value = "foo", "bar" return m mock_popen.side_effect = popen_fail with pytest.raises(subprocess.CalledProcessError) as exc_info: util.command_output(["taga", "\xc3\xa9"]) assert exc_info.value.returncode == 1 assert exc_info.value.cmd == "taga \xc3\xa9" def test_case_sensitive_default(self): path = util.bytestring_path( util.normpath( "/this/path/does/not/exist", ) ) assert util.case_sensitive(path) == (platform.system() != "Windows") @unittest.skipIf(sys.platform == "win32", "fs is not case sensitive") def test_case_sensitive_detects_sensitive(self): # FIXME: Add tests for more code paths of case_sensitive() # when the filesystem on the test runner is not case sensitive pass @unittest.skipIf(sys.platform != "win32", "fs is case sensitive") def test_case_sensitive_detects_insensitive(self): # FIXME: Add tests for more code paths of case_sensitive() # when the filesystem on the test runner is case sensitive pass class PathConversionTest(unittest.TestCase): def test_syspath_windows_format(self): with _common.platform_windows(): path = os.path.join("a", "b", "c") outpath = util.syspath(path) assert isinstance(outpath, str) assert outpath.startswith("\\\\?\\") def test_syspath_windows_format_unc_path(self): # The \\?\ prefix on Windows behaves differently with UNC # (network share) paths. path = "\\\\server\\share\\file.mp3" with _common.platform_windows(): outpath = util.syspath(path) assert isinstance(outpath, str) assert outpath == "\\\\?\\UNC\\server\\share\\file.mp3" def test_syspath_posix_unchanged(self): with _common.platform_posix(): path = os.path.join("a", "b", "c") outpath = util.syspath(path) assert path == outpath def _windows_bytestring_path(self, path): with _common.platform_windows(): return util.bytestring_path(path) def test_bytestring_path_windows_encodes_utf8(self): path = "caf\xe9" outpath = self._windows_bytestring_path(path) assert path == outpath.decode("utf-8") def test_bytesting_path_windows_removes_magic_prefix(self): path = "\\\\?\\C:\\caf\xe9" outpath = self._windows_bytestring_path(path) assert outpath == "C:\\caf\xe9".encode() class TestPathLegalization: _p = pytest.param @pytest.fixture(autouse=True) def _patch_max_filename_length(self, monkeypatch): monkeypatch.setattr("beets.util.get_max_filename_length", lambda: 5) @pytest.mark.parametrize( "path, expected", [ _p("abcdeX/fgh", "abcde/fgh", id="truncate-parent-dir"), _p("abcde/fXX.ext", "abcde/f.ext", id="truncate-filename"), # note that 🎹 is 4 bytes long: # >>> "🎹".encode("utf-8") # b'\xf0\x9f\x8e\xb9' _p("a🎹/a.ext", "a🎹/a.ext", id="unicode-fit"), _p("ab🎹/a.ext", "ab/a.ext", id="unicode-truncate-fully-one-byte-over-limit"), _p("f.a.e", "f.a.e", id="persist-dot-in-filename"), # see #5771 ], ) # fmt: skip def test_truncate(self, path, expected): path = path.replace("/", os.path.sep) expected = expected.replace("/", os.path.sep) assert util.truncate_path(path) == expected @pytest.mark.parametrize( "replacements, expected_path, expected_truncated", [ # [ repl before truncation, repl after truncation ] _p([ ], "_abcd", False, id="default"), _p([(r"abcdX$", "1ST"), ], ":1ST", False, id="1st_valid"), _p([(r"abcdX$", "TOO_LONG"), ], ":TOO_", False, id="1st_truncated"), _p([(r"abcdX$", "1ST"), (r"1ST$", "2ND") ], ":2ND", False, id="both_valid"), _p([(r"abcdX$", "TOO_LONG"), (r"TOO_$", "2ND") ], ":2ND", False, id="1st_truncated_2nd_valid"), _p([(r"abcdX$", "1ST"), (r"1ST$", "TOO_LONG") ], ":TOO_", False, id="1st_valid_2nd_truncated"), # if the logic truncates the path twice, it ends up applying the default replacements _p([(r"abcdX$", "TOO_LONG"), (r"TOO_$", "TOO_LONG") ], "_TOO_", True, id="both_truncated_default_repl_applied"), ] ) # fmt: skip def test_replacements( self, replacements, expected_path, expected_truncated ): replacements = [(re.compile(pat), repl) for pat, repl in replacements] assert util.legalize_path(":abcdX", replacements, "") == ( expected_path, expected_truncated, ) class TestPlurality: @pytest.mark.parametrize( "objs, expected_obj, expected_freq", [ pytest.param([1, 1, 1, 1], 1, 4, id="consensus"), pytest.param([1, 1, 2, 1], 1, 3, id="near consensus"), pytest.param([1, 1, 2, 2, 3], 1, 2, id="conflict-first-wins"), ], ) def test_plurality(self, objs, expected_obj, expected_freq): assert (expected_obj, expected_freq) == util.plurality(objs) def test_empty_sequence_raises_error(self): with pytest.raises(ValueError, match="must be non-empty"): util.plurality([]) def test_get_most_common_tags(self): items = [ Item(albumartist="aartist", label="label 1", album="album"), Item(albumartist="aartist", label="label 2", album="album"), Item(albumartist="aartist", label="label 3", album="another album"), ] likelies, consensus = util.get_most_common_tags(items) assert likelies["albumartist"] == "aartist" assert likelies["album"] == "album" # albumartist consensus overrides artist assert likelies["artist"] == "aartist" assert likelies["label"] == "label 1" assert likelies["year"] == 0 assert consensus["year"] assert consensus["albumartist"] assert not consensus["album"] assert not consensus["label"] ================================================ FILE: test/testall.py ================================================ #!/usr/bin/env python3 # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import sys pkgpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) or ".." sys.path.insert(0, pkgpath) ================================================ FILE: test/ui/__init__.py ================================================ ================================================ FILE: test/ui/commands/__init__.py ================================================ ================================================ FILE: test/ui/commands/test_completion.py ================================================ import os import subprocess import sys import pytest from beets.test import _common from beets.test.helper import IOMixin, has_program from beets.ui.commands.completion import BASH_COMPLETION_PATHS from beets.util import syspath from ..test_ui import TestPluginTestCase @_common.slow_test() @pytest.mark.xfail( os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform == "linux", reason="Completion is for some reason unhappy on Ubuntu 24.04 in CI", ) class CompletionTest(IOMixin, TestPluginTestCase): def test_completion(self): # Do not load any other bash completion scripts on the system. env = dict(os.environ) env["BASH_COMPLETION_DIR"] = os.devnull env["BASH_COMPLETION_COMPAT_DIR"] = os.devnull # Open a `bash` process to run the tests in. We'll pipe in bash # commands via stdin. cmd = os.environ.get("BEETS_TEST_SHELL", "/bin/bash --norc").split() if not has_program(cmd[0]): self.skipTest("bash not available") tester = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env ) # Load bash_completion library. for path in BASH_COMPLETION_PATHS: if os.path.exists(syspath(path)): bash_completion = path break else: self.skipTest("bash-completion script not found") try: with open(syspath(bash_completion), "rb") as f: tester.stdin.writelines(f) except OSError: self.skipTest("could not read bash-completion script") # Load completion script. self.run_command("completion", lib=None) completion_script = self.io.getoutput().encode("utf-8") tester.stdin.writelines(completion_script.splitlines(True)) # Load test suite. test_script_name = os.path.join(_common.RSRC, b"test_completion.sh") with open(test_script_name, "rb") as test_script_file: tester.stdin.writelines(test_script_file) out, _ = tester.communicate() assert tester.returncode == 0 assert out == b"completion tests passed\n", ( "test/test_completion.sh did not execute properly. " f"Output:{out.decode('utf-8')}" ) ================================================ FILE: test/ui/commands/test_config.py ================================================ import os from unittest.mock import patch import pytest import yaml from beets import config, ui from beets.test.helper import BeetsTestCase, IOMixin class ConfigCommandTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() for k in ("VISUAL", "EDITOR"): if k in os.environ: del os.environ[k] temp_dir = self.temp_dir.decode() self.config_path = os.path.join(temp_dir, "config.yaml") with open(self.config_path, "w") as file: file.write("library: lib\n") file.write("option: value\n") file.write("password: password_value") self.cli_config_path = os.path.join(temp_dir, "cli_config.yaml") with open(self.cli_config_path, "w") as file: file.write("option: cli overwrite") config.clear() config["password"].redact = True config._materialized = False def _run_with_yaml_output(self, *args): output = self.run_with_output(*args) return yaml.safe_load(output) def test_show_user_config(self): output = self._run_with_yaml_output("config", "-c") assert output["option"] == "value" assert output["password"] == "password_value" def test_show_user_config_with_defaults(self): output = self._run_with_yaml_output("config", "-dc") assert output["option"] == "value" assert output["password"] == "password_value" assert output["library"] == "lib" assert not output["import"]["timid"] def test_show_user_config_with_cli(self): output = self._run_with_yaml_output( "--config", self.cli_config_path, "config" ) assert output["library"] == "lib" assert output["option"] == "cli overwrite" def test_show_redacted_user_config(self): output = self._run_with_yaml_output("config") assert output["option"] == "value" assert output["password"] == "REDACTED" def test_show_redacted_user_config_with_defaults(self): output = self._run_with_yaml_output("config", "-d") assert output["option"] == "value" assert output["password"] == "REDACTED" assert not output["import"]["timid"] def test_config_paths(self): output = self.run_with_output("config", "-p") paths = output.split("\n") assert len(paths) == 2 assert paths[0] == self.config_path def test_config_paths_with_cli(self): output = self.run_with_output( "--config", self.cli_config_path, "config", "-p" ) paths = output.split("\n") assert len(paths) == 3 assert paths[0] == self.cli_config_path def test_edit_config_with_visual_or_editor_env(self): os.environ["EDITOR"] = "myeditor" with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with("myeditor", "myeditor", self.config_path) os.environ["VISUAL"] = "" # empty environment variables gets ignored with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with("myeditor", "myeditor", self.config_path) os.environ["VISUAL"] = "myvisual" with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with("myvisual", "myvisual", self.config_path) def test_edit_config_with_automatic_open(self): with patch("beets.util.open_anything") as open: open.return_value = "please_open" with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with( "please_open", "please_open", self.config_path ) def test_config_editor_not_found(self): msg_match = "Could not edit configuration.*here is problem" with ( patch("os.execlp", side_effect=OSError("here is problem")), pytest.raises(ui.UserError, match=msg_match), ): self.run_command("config", "-e") def test_edit_invalid_config_file(self): with open(self.config_path, "w") as file: file.write("invalid: [") config.clear() config._materialized = False os.environ["EDITOR"] = "myeditor" with patch("os.execlp") as execlp: self.run_command("config", "-e") execlp.assert_called_once_with("myeditor", "myeditor", self.config_path) def test_edit_config_with_custom_config_path(self): os.environ["EDITOR"] = "myeditor" with patch("os.execlp") as execlp: self.run_command("--config", self.cli_config_path, "config", "-e") execlp.assert_called_once_with( "myeditor", "myeditor", self.cli_config_path ) ================================================ FILE: test/ui/commands/test_fields.py ================================================ from beets import library from beets.test.helper import IOMixin, ItemInDBTestCase from beets.ui.commands.fields import fields_func class FieldsTest(IOMixin, ItemInDBTestCase): def remove_keys(self, keys, text): for i in text: try: keys.remove(i) except ValueError: pass def test_fields_func(self): fields_func(self.lib, [], []) items = library.Item.all_keys() albums = library.Album.all_keys() output = self.io.getoutput().split() self.remove_keys(items, output) self.remove_keys(albums, output) assert len(items) == 0 assert len(albums) == 0 ================================================ FILE: test/ui/commands/test_import.py ================================================ import os import unittest from unittest.mock import Mock, patch import pytest from beets import autotag, config, library, ui from beets.autotag.match import distance from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.import_ import import_files, paths_from_logfile from beets.ui.commands.import_.display import show_change from beets.ui.commands.import_.session import summarize_items class ImportTest(BeetsTestCase): def test_quiet_timid_disallowed(self): config["import"]["quiet"] = True config["import"]["timid"] = True with pytest.raises(ui.UserError): import_files(None, [], None) def test_parse_paths_from_logfile(self): if os.path.__name__ == "ntpath": logfile_content = ( "import started Wed Jun 15 23:08:26 2022\n" "asis C:\\music\\Beatles, The\\The Beatles; C:\\music\\Beatles, The\\The Beatles\\CD 01; C:\\music\\Beatles, The\\The Beatles\\CD 02\n" # noqa: E501 "duplicate-replace C:\\music\\Bill Evans\\Trio '65\n" "skip C:\\music\\Michael Jackson\\Bad\n" "skip C:\\music\\Soulwax\\Any Minute Now\n" ) expected_paths = [ "C:\\music\\Beatles, The\\The Beatles", "C:\\music\\Michael Jackson\\Bad", "C:\\music\\Soulwax\\Any Minute Now", ] else: logfile_content = ( "import started Wed Jun 15 23:08:26 2022\n" "asis /music/Beatles, The/The Beatles; /music/Beatles, The/The Beatles/CD 01; /music/Beatles, The/The Beatles/CD 02\n" # noqa: E501 "duplicate-replace /music/Bill Evans/Trio '65\n" "skip /music/Michael Jackson/Bad\n" "skip /music/Soulwax/Any Minute Now\n" ) expected_paths = [ "/music/Beatles, The/The Beatles", "/music/Michael Jackson/Bad", "/music/Soulwax/Any Minute Now", ] logfile = os.path.join(self.temp_dir, b"logfile.log") with open(logfile, mode="w") as fp: fp.write(logfile_content) actual_paths = list(paths_from_logfile(logfile)) assert actual_paths == expected_paths @patch("beets.ui.term_width", Mock(return_value=54)) class ShowChangeTestCase(IOMixin, BeetsTestCase): def _show_change(self): """Return an unicode string representing the changes""" long_name = f"a{' very' * 10} long name" items = [ _common.item(track=1, title="first title"), _common.item(track=2, title="", path=b"/path/to/file.mp3"), _common.item(track=3, title="caf\xe9"), _common.item(track=4, title=f"title with {long_name}"), ] info = autotag.AlbumInfo( album="caf\xe9", album_id="album id", artist="the artist", artist_id="artist id", tracks=[ autotag.TrackInfo(title="first title", index=1), autotag.TrackInfo(title="second title", index=2), autotag.TrackInfo(title="third title", index=3), autotag.TrackInfo(title="fourth title", index=4), ], ) item_info_pairs = list(zip(items, info.tracks)) self.config["ui"]["color"] = False self.config["import"]["detail"] = True change_dist = distance(items, info, item_info_pairs) change_dist._penalties = {"album": [0.1], "artist": [0.1]} show_change( f"another artist with {long_name}", "another album", autotag.AlbumMatch( change_dist, info, dict(item_info_pairs), set(), set() ), ) return self.io.getoutput() def test_newline_layout(self): self.config["ui"]["import"]["layout"] = "newline" msg = self._show_change() assert ( msg == """ Match (90.0%): the artist - café ≠ album, artist None, None, None, None, None, None, None ≠ Artist: another artist with a very very very very very very very very very very long name -> the artist ≠ Album: another album -> café * (#1) first title (1:00) ≠ (#2) file.mp3 (1:00) -> (#2) second title (0:00) ≠ (#3) café (1:00) -> (#3) third title (0:00) ≠ (#4) title with a very very very very very very very very very very long name (1:00) -> (#4) fourth title (0:00) """ ) def test_column_layout(self): self.config["ui"]["import"]["layout"] = "column" msg = self._show_change() assert ( msg == """ Match (90.0%): the artist - café ≠ album, artist None, None, None, None, None, None, None ≠ Artist: another artist -> the artist with a very very very very very very very very very very long name ≠ Album: another album -> café * (#1) first title (1:00) ≠ (#2) file.mp (1:00) -> (#2) second (0:00) 3 title ≠ (#3) café (1:00) -> (#3) third title (0:00) ≠ (#4) title (1:00) -> (#4) fourth (0:00) with a very title very very very very very very very very very long name """ # noqa: W291 ) @patch("beets.library.Item.try_filesize", Mock(return_value=987)) class SummarizeItemsTest(unittest.TestCase): def setUp(self): super().setUp() item = library.Item() item.bitrate = 4321 item.length = 10 * 60 + 54 item.format = "F" self.item = item def test_summarize_item(self): summary = summarize_items([], True) assert summary == "" summary = summarize_items([self.item], True) assert summary == "F, 4kbps, 10:54, 987.0 B" def test_summarize_items(self): summary = summarize_items([], False) assert summary == "0 items" summary = summarize_items([self.item], False) assert summary == "1 items, F, 4kbps, 10:54, 987.0 B" # make a copy of self.item i2 = self.item.copy() summary = summarize_items([self.item, i2], False) assert summary == "2 items, F, 4kbps, 21:48, 1.9 KiB" i2.format = "G" summary = summarize_items([self.item, i2], False) assert summary == "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB" summary = summarize_items([self.item, i2, i2], False) assert summary == "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB" ================================================ FILE: test/ui/commands/test_list.py ================================================ from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.list import list_items class ListTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() self.item = _common.item() self.item.path = "xxx/yyy" self.lib.add(self.item) self.lib.add_album([self.item]) def _run_list(self, query="", album=False, path=False, fmt=""): list_items(self.lib, query, album, fmt) return self.io.getoutput() def test_list_outputs_item(self): stdout = self._run_list() assert "the title" in stdout def test_list_unicode_query(self): self.item.title = "na\xefve" self.item.store() self.lib._connection().commit() stdout = self._run_list(["na\xefve"]) out = stdout assert "na\xefve" in out def test_list_item_path(self): stdout = self._run_list(fmt="$path") assert stdout.strip() == "xxx/yyy" def test_list_album_outputs_something(self): stdout = self._run_list(album=True) assert len(stdout) > 0 def test_list_album_path(self): stdout = self._run_list(album=True, fmt="$path") assert stdout.strip() == "xxx" def test_list_album_omits_title(self): stdout = self._run_list(album=True) assert "the title" not in stdout def test_list_uses_track_artist(self): stdout = self._run_list() assert "the artist" in stdout assert "the album artist" not in stdout def test_list_album_uses_album_artist(self): stdout = self._run_list(album=True) assert "the artist" not in stdout assert "the album artist" in stdout def test_list_item_format_artist(self): stdout = self._run_list(fmt="$artist") assert "the artist" in stdout def test_list_item_format_multiple(self): stdout = self._run_list(fmt="$artist - $album - $year") assert "the artist - the album - 0001" == stdout.strip() def test_list_album_format(self): stdout = self._run_list(album=True, fmt="$genres") assert "the genre" in stdout assert "the album" not in stdout ================================================ FILE: test/ui/commands/test_modify.py ================================================ import unittest from mediafile import MediaFile from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.modify import modify_parse_args from beets.util import syspath class ModifyTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() self.album = self.add_album_fixture() [self.item] = self.album.items() def modify_inp(self, inp: list[str], *args): for chat in inp: self.io.addinput(chat) self.run_command("modify", *args) def modify(self, *args): self.modify_inp(["y"], *args) # Item tests def test_modify_item(self): self.modify("title=newTitle") item = self.lib.items().get() assert item.title == "newTitle" def test_modify_item_abort(self): item = self.lib.items().get() title = item.title self.modify_inp(["n"], "title=newTitle") item = self.lib.items().get() assert item.title == title def test_modify_item_no_change(self): title = "Tracktitle" item = self.add_item_fixture(title=title) self.modify_inp(["y"], "title", f"title={title}") item = self.lib.items(title).get() assert item.title == title def test_modify_write_tags(self): self.modify("title=newTitle") item = self.lib.items().get() item.read() assert item.title == "newTitle" def test_modify_dont_write_tags(self): self.modify("--nowrite", "title=newTitle") item = self.lib.items().get() item.read() assert item.title != "newTitle" def test_move(self): self.modify("title=newTitle") item = self.lib.items().get() assert b"newTitle" in item.path def test_not_move(self): self.modify("--nomove", "title=newTitle") item = self.lib.items().get() assert b"newTitle" not in item.path def test_no_write_no_move(self): self.modify("--nomove", "--nowrite", "title=newTitle") item = self.lib.items().get() item.read() assert b"newTitle" not in item.path assert item.title != "newTitle" def test_update_mtime(self): item = self.item old_mtime = item.mtime self.modify("title=newTitle") item.load() assert old_mtime != item.mtime assert item.current_mtime() == item.mtime def test_reset_mtime_with_no_write(self): item = self.item self.modify("--nowrite", "title=newTitle") item.load() assert 0 == item.mtime def test_selective_modify(self): title = "Tracktitle" album = "album" original_artist = "composer" new_artist = "coverArtist" for i in range(0, 10): self.add_item_fixture( title=f"{title}{i}", artist=original_artist, album=album ) self.modify_inp( ["s", "y", "y", "y", "n", "n", "y", "y", "y", "y", "n"], title, f"artist={new_artist}", ) original_items = self.lib.items(f"artist:{original_artist}") new_items = self.lib.items(f"artist:{new_artist}") assert len(list(original_items)) == 3 assert len(list(new_items)) == 7 def test_modify_formatted(self): for i in range(0, 3): self.add_item_fixture( title=f"title{i}", artist="artist", album="album" ) items = list(self.lib.items()) self.modify("title=${title} - append") for item in items: orig_title = item.title item.load() assert item.title == f"{orig_title} - append" # Album Tests def test_modify_album(self): self.modify("--album", "album=newAlbum") album = self.lib.albums().get() assert album.album == "newAlbum" def test_modify_album_write_tags(self): self.modify("--album", "album=newAlbum") item = self.lib.items().get() item.read() assert item.album == "newAlbum" def test_modify_album_dont_write_tags(self): self.modify("--album", "--nowrite", "album=newAlbum") item = self.lib.items().get() item.read() assert item.album == "the album" def test_album_move(self): self.modify("--album", "album=newAlbum") item = self.lib.items().get() item.read() assert b"newAlbum" in item.path def test_album_not_move(self): self.modify("--nomove", "--album", "album=newAlbum") item = self.lib.items().get() item.read() assert b"newAlbum" not in item.path def test_modify_album_formatted(self): item = self.lib.items().get() orig_album = item.album self.modify("--album", "album=${album} - append") item.load() assert item.album == f"{orig_album} - append" # Misc def test_write_initial_key_tag(self): self.modify("initial_key=C#m") item = self.lib.items().get() mediafile = MediaFile(syspath(item.path)) assert mediafile.initial_key == "C#m" def test_set_flexattr(self): self.modify("flexattr=testAttr") item = self.lib.items().get() assert item.flexattr == "testAttr" def test_remove_flexattr(self): item = self.lib.items().get() item.flexattr = "testAttr" item.store() self.modify("flexattr!") item = self.lib.items().get() assert "flexattr" not in item @unittest.skip("not yet implemented") def test_delete_initial_key_tag(self): item = self.lib.items().get() item.initial_key = "C#m" item.write() item.store() mediafile = MediaFile(syspath(item.path)) assert mediafile.initial_key == "C#m" self.modify("initial_key!") mediafile = MediaFile(syspath(item.path)) assert mediafile.initial_key is None def test_arg_parsing_colon_query(self): query, mods, _ = modify_parse_args(["title:oldTitle", "title=newTitle"]) assert query == ["title:oldTitle"] assert mods == {"title": "newTitle"} def test_arg_parsing_delete(self): query, _, dels = modify_parse_args(["title:oldTitle", "title!"]) assert query == ["title:oldTitle"] assert dels == ["title"] def test_arg_parsing_query_with_exclaimation(self): query, mods, _ = modify_parse_args( ["title:oldTitle!", "title=newTitle!"] ) assert query == ["title:oldTitle!"] assert mods == {"title": "newTitle!"} def test_arg_parsing_equals_in_value(self): query, mods, _ = modify_parse_args(["title:foo=bar", "title=newTitle"]) assert query == ["title:foo=bar"] assert mods == {"title": "newTitle"} ================================================ FILE: test/ui/commands/test_move.py ================================================ import shutil from beets import library from beets.test.helper import BeetsTestCase from beets.ui.commands.move import move_items class MoveTest(BeetsTestCase): def setUp(self): super().setUp() self.initial_item_path = self.lib_path / "srcfile" shutil.copy(self.resource_path, self.initial_item_path) # Add a file to the library but don't copy it in yet. self.i = library.Item.from_path(self.initial_item_path) self.lib.add(self.i) self.album = self.lib.add_album([self.i]) # Alternate destination directory. self.otherdir = self.temp_dir_path / "testotherdir" def _move( self, query=(), dest=None, copy=False, album=False, pretend=False, export=False, ): move_items(self.lib, dest, query, copy, album, pretend, export=export) def test_move_item(self): self._move() self.i.load() assert b"libdir" in self.i.path assert self.i.filepath.exists() assert not self.initial_item_path.exists() def test_copy_item(self): self._move(copy=True) self.i.load() assert b"libdir" in self.i.path assert self.i.filepath.exists() assert self.initial_item_path.exists() def test_move_album(self): self._move(album=True) self.i.load() assert b"libdir" in self.i.path assert self.i.filepath.exists() assert not self.initial_item_path.exists() def test_copy_album(self): self._move(copy=True, album=True) self.i.load() assert b"libdir" in self.i.path assert self.i.filepath.exists() assert self.initial_item_path.exists() def test_move_item_custom_dir(self): self._move(dest=self.otherdir) self.i.load() assert b"testotherdir" in self.i.path assert self.i.filepath.exists() assert not self.initial_item_path.exists() def test_move_album_custom_dir(self): self._move(dest=self.otherdir, album=True) self.i.load() assert b"testotherdir" in self.i.path assert self.i.filepath.exists() assert not self.initial_item_path.exists() def test_pretend_move_item(self): self._move(dest=self.otherdir, pretend=True) self.i.load() assert self.i.filepath == self.initial_item_path def test_pretend_move_album(self): self._move(album=True, pretend=True) self.i.load() assert self.i.filepath == self.initial_item_path def test_export_item_custom_dir(self): self._move(dest=self.otherdir, export=True) self.i.load() assert self.i.filepath == self.initial_item_path assert self.otherdir.exists() def test_export_album_custom_dir(self): self._move(dest=self.otherdir, album=True, export=True) self.i.load() assert self.i.filepath == self.initial_item_path assert self.otherdir.exists() def test_pretend_export_item(self): self._move(dest=self.otherdir, pretend=True, export=True) self.i.load() assert self.i.filepath == self.initial_item_path assert not self.otherdir.exists() ================================================ FILE: test/ui/commands/test_remove.py ================================================ import os from beets import library from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.remove import remove_items from beets.util import MoveOperation, syspath class RemoveTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() # Copy a file into the library. self.i = library.Item.from_path(self.resource_path) self.lib.add(self.i) self.i.move(operation=MoveOperation.COPY) def test_remove_items_no_delete(self): self.io.addinput("y") remove_items(self.lib, "", False, False, False) items = self.lib.items() assert len(list(items)) == 0 assert self.i.filepath.exists() def test_remove_items_with_delete(self): self.io.addinput("y") remove_items(self.lib, "", False, True, False) items = self.lib.items() assert len(list(items)) == 0 assert not self.i.filepath.exists() def test_remove_items_with_force_no_delete(self): remove_items(self.lib, "", False, False, True) items = self.lib.items() assert len(list(items)) == 0 assert self.i.filepath.exists() def test_remove_items_with_force_delete(self): remove_items(self.lib, "", False, True, True) items = self.lib.items() assert len(list(items)) == 0 assert not self.i.filepath.exists() def test_remove_items_select_with_delete(self): i2 = library.Item.from_path(self.resource_path) self.lib.add(i2) i2.move(operation=MoveOperation.COPY) for s in ("s", "y", "n"): self.io.addinput(s) remove_items(self.lib, "", False, True, False) items = self.lib.items() assert len(list(items)) == 1 # There is probably no guarantee that the items are queried in any # spcecific order, thus just ensure that exactly one was removed. # To improve upon this, self.io would need to have the capability to # generate input that depends on previous output. num_existing = 0 num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 assert num_existing == 1 def test_remove_albums_select_with_delete(self): a1 = self.add_album_fixture() a2 = self.add_album_fixture() path1 = a1.items()[0].path path2 = a2.items()[0].path items = self.lib.items() assert len(list(items)) == 3 for s in ("s", "y", "n"): self.io.addinput(s) remove_items(self.lib, "", True, True, False) items = self.lib.items() assert len(list(items)) == 2 # incl. the item from setUp() # See test_remove_items_select_with_delete() num_existing = 0 num_existing += 1 if os.path.exists(syspath(path1)) else 0 num_existing += 1 if os.path.exists(syspath(path2)) else 0 assert num_existing == 1 ================================================ FILE: test/ui/commands/test_update.py ================================================ import os from mediafile import MediaFile from beets import library from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.update import update_items from beets.util import MoveOperation, remove, syspath class UpdateTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() # Copy a file into the library. item_path = os.path.join(_common.RSRC, b"full.mp3") item_path_two = os.path.join(_common.RSRC, b"full.flac") self.i = library.Item.from_path(item_path) self.i2 = library.Item.from_path(item_path_two) self.lib.add(self.i) self.lib.add(self.i2) self.i.move(operation=MoveOperation.COPY) self.i2.move(operation=MoveOperation.COPY) self.album = self.lib.add_album([self.i, self.i2]) # Album art. artfile = os.path.join(self.temp_dir, b"testart.jpg") _common.touch(artfile) self.album.set_art(artfile) self.album.store() remove(artfile) def _update( self, query=(), album=False, move=False, reset_mtime=True, fields=None, exclude_fields=None, ): self.io.addinput("y") if reset_mtime: self.i.mtime = 0 self.i.store() update_items( self.lib, query, album, move, False, fields=fields, exclude_fields=exclude_fields, ) def test_delete_removes_item(self): assert list(self.lib.items()) remove(self.i.path) remove(self.i2.path) self._update() assert not list(self.lib.items()) def test_delete_removes_album(self): assert self.lib.albums() remove(self.i.path) remove(self.i2.path) self._update() assert not self.lib.albums() def test_delete_removes_album_art(self): art_filepath = self.album.art_filepath assert art_filepath.exists() remove(self.i.path) remove(self.i2.path) self._update() assert not art_filepath.exists() def test_modified_metadata_detected(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.save() self._update() item = self.lib.items().get() assert item.title == "differentTitle" def test_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.save() self._update(move=True) item = self.lib.items().get() assert b"differentTitle" in item.path def test_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.save() self._update(move=False) item = self.lib.items().get() assert b"differentTitle" not in item.path def test_selective_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.genres = ["differentGenre"] mf.save() self._update(move=True, fields=["title"]) item = self.lib.items().get() assert b"differentTitle" in item.path assert item.genres != ["differentGenre"] def test_selective_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.genres = ["differentGenre"] mf.save() self._update(move=False, fields=["title"]) item = self.lib.items().get() assert b"differentTitle" not in item.path assert item.genres != ["differentGenre"] def test_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" mf.save() self._update(move=True) item = self.lib.items().get() assert b"differentAlbum" in item.path def test_modified_album_metadata_art_moved(self): artpath = self.album.artpath mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" mf.save() self._update(move=True) album = self.lib.albums()[0] assert artpath != album.artpath assert album.artpath is not None def test_selective_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" mf.genres = ["differentGenre"] mf.save() self._update(move=True, fields=["album"]) item = self.lib.items().get() assert b"differentAlbum" in item.path assert item.genres != ["differentGenre"] def test_selective_modified_album_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = "differentAlbum" mf.genres = ["differentGenre"] mf.save() self._update(move=True, fields=["genres"]) item = self.lib.items().get() assert b"differentAlbum" not in item.path assert item.genres == ["differentGenre"] def test_mtime_match_skips_update(self): mf = MediaFile(syspath(self.i.path)) mf.title = "differentTitle" mf.save() # Make in-memory mtime match on-disk mtime. self.i.mtime = os.path.getmtime(syspath(self.i.path)) self.i.store() self._update(reset_mtime=False) item = self.lib.items().get() assert item.title == "full" def test_multivalued_albumtype_roundtrip(self): # https://github.com/beetbox/beets/issues/4528 # albumtypes is empty for our test fixtures, so populate it first album = self.album correct_albumtypes = ["album", "live"] # Setting albumtypes does not set albumtype, currently. # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 correct_albumtype = correct_albumtypes[0] album.albumtype = correct_albumtype album.albumtypes = correct_albumtypes album.try_sync(write=True, move=False) album.load() assert album.albumtype == correct_albumtype assert album.albumtypes == correct_albumtypes self._update() album.load() assert album.albumtype == correct_albumtype assert album.albumtypes == correct_albumtypes def test_modified_metadata_excluded(self): mf = MediaFile(syspath(self.i.path)) mf.lyrics = "new lyrics" mf.save() self._update(exclude_fields=["lyrics"]) item = self.lib.items().get() assert item.lyrics != "new lyrics" ================================================ FILE: test/ui/commands/test_utils.py ================================================ import os import shutil import pytest from beets import library, ui from beets.test import _common from beets.test.helper import BeetsTestCase from beets.ui.commands.utils import do_query from beets.util import syspath class QueryTest(BeetsTestCase): def add_item(self, filename=b"srcfile", templatefile=b"full.mp3"): itempath = os.path.join(self.libdir, filename) shutil.copy( syspath(os.path.join(_common.RSRC, templatefile)), syspath(itempath), ) item = library.Item.from_path(itempath) self.lib.add(item) return item def add_album(self, items): album = self.lib.add_album(items) return album def check_do_query( self, num_items, num_albums, q=(), album=False, also_items=True ): items, albums = do_query(self.lib, q, album, also_items) assert len(items) == num_items assert len(albums) == num_albums def test_query_empty(self): with pytest.raises(ui.UserError): do_query(self.lib, (), False) def test_query_empty_album(self): with pytest.raises(ui.UserError): do_query(self.lib, (), True) def test_query_item(self): self.add_item() self.check_do_query(1, 0, album=False) self.add_item() self.check_do_query(2, 0, album=False) def test_query_album(self): item = self.add_item() self.add_album([item]) self.check_do_query(1, 1, album=True) self.check_do_query(0, 1, album=True, also_items=False) item = self.add_item() item2 = self.add_item() self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) ================================================ FILE: test/ui/commands/test_write.py ================================================ from beets.test.helper import BeetsTestCase, IOMixin class WriteTest(IOMixin, BeetsTestCase): def write_cmd(self, *args): return self.run_with_output("write", *args) def test_update_mtime(self): item = self.add_item_fixture() item["title"] = "a new title" item.store() item = self.lib.items().get() assert item.mtime == 0 self.write_cmd() item = self.lib.items().get() assert item.mtime == item.current_mtime() def test_non_metadata_field_unchanged(self): """Changing a non-"tag" field like `bitrate` and writing should have no effect. """ # An item that starts out "clean". item = self.add_item_fixture() item.read() # ... but with a mismatched bitrate. item.bitrate = 123 item.store() output = self.write_cmd() assert output == "" def test_write_metadata_field(self): item = self.add_item_fixture() item.read() old_title = item.title item.title = "new title" item.store() output = self.write_cmd() assert f"{old_title} -> new title" in output ================================================ FILE: test/ui/test_ui.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the command-line interface.""" import os import platform import sys import unittest from pathlib import Path from unittest.mock import patch import pytest from confuse import ConfigError from beets import config, plugins, ui from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin, PluginTestCase from beets.ui import commands from beets.util import syspath class PrintTest(IOMixin, unittest.TestCase): def test_print_without_locale(self): lang = os.environ.get("LANG") if lang: del os.environ["LANG"] try: ui.print_("something") except TypeError: self.fail("TypeError during print") finally: if lang: os.environ["LANG"] = lang def test_print_with_invalid_locale(self): old_lang = os.environ.get("LANG") os.environ["LANG"] = "" old_ctype = os.environ.get("LC_CTYPE") os.environ["LC_CTYPE"] = "UTF-8" try: ui.print_("something") except ValueError: self.fail("ValueError during print") finally: if old_lang: os.environ["LANG"] = old_lang else: del os.environ["LANG"] if old_ctype: os.environ["LC_CTYPE"] = old_ctype else: del os.environ["LC_CTYPE"] class ShowModelChangesTest(IOMixin, BeetsTestCase): def test_uses_database_state_when_old_not_provided(self): item = self.add_item_fixture(title="old title") old_label = format(item.get_fresh_from_db()) item.title = "new title" assert ui.show_model_changes(item) is True assert self.io.getoutput().splitlines() == [ old_label, " title: old title -> new title", ] @_common.slow_test() class TestPluginTestCase(PluginTestCase): plugin = "test" def setUp(self): self.config["pluginpath"] = [_common.PLUGINPATH] super().setUp() class ConfigTest(IOMixin, TestPluginTestCase): def setUp(self): super().setUp() # Don't use the BEETSDIR from `helper`. Instead, we point the home # directory there. Some tests will set `BEETSDIR` themselves. del os.environ["BEETSDIR"] # Also set APPDATA, the Windows equivalent of setting $HOME. appdata_dir = self.temp_dir_path / "AppData" / "Roaming" self._orig_cwd = os.getcwd() self.test_cmd = self._make_test_cmd() commands.default_commands.append(self.test_cmd) # Default user configuration if platform.system() == "Windows": self.user_config_dir = appdata_dir / "beets" else: self.user_config_dir = self.temp_dir_path / ".config" / "beets" self.user_config_dir.mkdir(parents=True, exist_ok=True) self.user_config_path = self.user_config_dir / "config.yaml" # Custom BEETSDIR self.beetsdir = self.temp_dir_path / "beetsdir" self.beetsdir.mkdir(parents=True, exist_ok=True) self.env_config_path = str(self.beetsdir / "config.yaml") self.cli_config_path = str(self.temp_dir_path / "config.yaml") self.env_patcher = patch( "os.environ", {"HOME": str(self.temp_dir_path), "APPDATA": str(appdata_dir)}, ) self.env_patcher.start() self._reset_config() def tearDown(self): self.env_patcher.stop() commands.default_commands.pop() os.chdir(syspath(self._orig_cwd)) super().tearDown() def _make_test_cmd(self): test_cmd = ui.Subcommand("test", help="test") def run(lib, options, args): test_cmd.lib = lib test_cmd.options = options test_cmd.args = args test_cmd.func = run return test_cmd def _reset_config(self): # Config should read files again on demand config.clear() config._materialized = False def write_config_file(self): return open(self.user_config_path, "w") def test_paths_section_respected(self): with self.write_config_file() as config: config.write("paths: {x: y}") self.run_command("test", lib=None) key, template = self.test_cmd.lib.path_formats[0] assert key == "x" assert template.original == "y" def test_default_paths_preserved(self): default_formats = ui.get_path_formats() self._reset_config() with self.write_config_file() as config: config.write("paths: {x: y}") self.run_command("test", lib=None) key, template = self.test_cmd.lib.path_formats[0] assert key == "x" assert template.original == "y" assert self.test_cmd.lib.path_formats[1:] == default_formats def test_nonexistant_db(self): with self.write_config_file() as config: config.write("library: /xxx/yyy/not/a/real/path") self.io.addinput("n") with pytest.raises(ui.UserError): self.run_command("test", lib=None) def test_user_config_file(self): with self.write_config_file() as file: file.write("anoption: value") self.run_command("test", lib=None) assert config["anoption"].get() == "value" def test_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z}") self.run_command("test", lib=None) replacements = self.test_cmd.lib.replacements repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. assert repls == [("[xy]", "z")] def test_multiple_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z, foo: bar}") self.run_command("test", lib=None) replacements = self.test_cmd.lib.replacements repls = [(p.pattern, s) for p, s in replacements] assert repls == [("[xy]", "z"), ("foo", "bar")] def test_cli_config_option(self): with open(self.cli_config_path, "w") as file: file.write("anoption: value") self.run_command("--config", self.cli_config_path, "test", lib=None) assert config["anoption"].get() == "value" def test_cli_config_file_overwrites_user_defaults(self): with open(self.user_config_path, "w") as file: file.write("anoption: value") with open(self.cli_config_path, "w") as file: file.write("anoption: cli overwrite") self.run_command("--config", self.cli_config_path, "test", lib=None) assert config["anoption"].get() == "cli overwrite" def test_cli_config_file_overwrites_beetsdir_defaults(self): os.environ["BEETSDIR"] = str(self.beetsdir) with open(self.env_config_path, "w") as file: file.write("anoption: value") with open(self.cli_config_path, "w") as file: file.write("anoption: cli overwrite") self.run_command("--config", self.cli_config_path, "test", lib=None) assert config["anoption"].get() == "cli overwrite" # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_files(self): # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') # # with open(cli_config_path_1, 'w') as file: # file.write('first: value') # # with open(cli_config_path_2, 'w') as file: # file.write('second: value') # # self.run_command('--config', cli_config_path_1, # '--config', cli_config_path_2, 'test', lib=None) # assert config['first'].get() == 'value' # assert config['second'].get() == 'value' # # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_overwrite(self): # cli_overwrite_config_path = os.path.join(self.temp_dir, # b'overwrite_config.yaml') # # with open(self.cli_config_path, 'w') as file: # file.write('anoption: value') # # with open(cli_overwrite_config_path, 'w') as file: # file.write('anoption: overwrite') # # self.run_command('--config', self.cli_config_path, # '--config', cli_overwrite_config_path, 'test') # assert config['anoption'].get() == 'cli overwrite' # FIXME: fails on windows @unittest.skipIf(sys.platform == "win32", "win32") def test_cli_config_paths_resolve_relative_to_user_dir(self): with open(self.cli_config_path, "w") as file: file.write("library: beets.db\n") file.write("statefile: state") self.run_command("--config", self.cli_config_path, "test", lib=None) assert config["library"].as_path() == self.user_config_dir / "beets.db" assert config["statefile"].as_path() == self.user_config_dir / "state" def test_cli_config_paths_resolve_relative_to_beetsdir(self): os.environ["BEETSDIR"] = str(self.beetsdir) with open(self.cli_config_path, "w") as file: file.write("library: beets.db\n") file.write("statefile: state") self.run_command("--config", self.cli_config_path, "test", lib=None) assert config["library"].as_path() == self.beetsdir / "beets.db" assert config["statefile"].as_path() == self.beetsdir / "state" def test_command_line_option_relative_to_working_dir(self): config.read() os.chdir(syspath(self.temp_dir)) self.run_command("--library", "foo.db", "test", lib=None) assert config["library"].as_path() == Path.cwd() / "foo.db" def test_cli_config_file_loads_plugin_commands(self): with open(self.cli_config_path, "w") as file: file.write(f"pluginpath: {_common.PLUGINPATH}\n") file.write("plugins: test") self.run_command("--config", self.cli_config_path, "plugin", lib=None) plugs = plugins.find_plugins() assert len(plugs) == 1 assert plugs[0].is_test_plugin self.unload_plugins() def test_beetsdir_config(self): os.environ["BEETSDIR"] = str(self.beetsdir) with open(self.env_config_path, "w") as file: file.write("anoption: overwrite") config.read() assert config["anoption"].get() == "overwrite" def test_beetsdir_points_to_file_error(self): beetsdir = str(self.temp_dir_path / "beetsfile") open(beetsdir, "a").close() os.environ["BEETSDIR"] = beetsdir with pytest.raises(ConfigError): self.run_command("test") def test_beetsdir_config_does_not_load_default_user_config(self): os.environ["BEETSDIR"] = str(self.beetsdir) with open(self.user_config_path, "w") as file: file.write("anoption: value") config.read() assert not config["anoption"].exists() def test_default_config_paths_resolve_relative_to_beetsdir(self): os.environ["BEETSDIR"] = str(self.beetsdir) config.read() assert config["library"].as_path() == self.beetsdir / "library.db" assert config["statefile"].as_path() == self.beetsdir / "state.pickle" def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): os.environ["BEETSDIR"] = str(self.beetsdir) with open(self.env_config_path, "w") as file: file.write("library: beets.db\n") file.write("statefile: state") config.read() assert config["library"].as_path() == self.beetsdir / "beets.db" assert config["statefile"].as_path() == self.beetsdir / "state" class PathFormatTest(unittest.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() config["paths"] = {"foo": "bar"} pf = ui.get_path_formats() key, tmpl = pf[0] assert key == "foo" assert tmpl.original == "bar" assert pf[1:] == default_formats @_common.slow_test() class PluginTest(TestPluginTestCase): def test_plugin_command_from_pluginpath(self): self.run_command("test", lib=None) class CommonOptionsParserCliTest(IOMixin, BeetsTestCase): """Test CommonOptionsParser and formatting LibModel formatting on 'list' command. """ def setUp(self): super().setUp() self.item = _common.item() self.item.path = b"xxx/yyy" self.lib.add(self.item) self.lib.add_album([self.item]) def test_base(self): output = self.run_with_output("ls") assert output == "the artist - the album - the title\n" output = self.run_with_output("ls", "-a") assert output == "the album artist - the album\n" def test_path_option(self): output = self.run_with_output("ls", "-p") assert output == "xxx/yyy\n" output = self.run_with_output("ls", "-a", "-p") assert output == "xxx\n" def test_format_option(self): output = self.run_with_output("ls", "-f", "$artist") assert output == "the artist\n" output = self.run_with_output("ls", "-a", "-f", "$albumartist") assert output == "the album artist\n" def test_format_option_unicode(self): output = self.run_with_output("ls", "-f", "caf\xe9") assert output == "caf\xe9\n" def test_root_format_option(self): output = self.run_with_output( "--format-item", "$artist", "--format-album", "foo", "ls" ) assert output == "the artist\n" output = self.run_with_output( "--format-item", "foo", "--format-album", "$albumartist", "ls", "-a" ) assert output == "the album artist\n" def test_help(self): output = self.run_with_output("help") assert "Usage:" in output output = self.run_with_output("help", "list") assert "Usage:" in output with pytest.raises(ui.UserError): self.run_command("help", "this.is.not.a.real.command") def test_stats(self): output = self.run_with_output("stats") assert "Approximate total size:" in output # # Need to have more realistic library setup for this to work # output = self.run_with_output('stats', '-e') # assert 'Total size:' in output def test_version(self): output = self.run_with_output("version") assert "Python version" in output assert "no plugins loaded" in output # # Need to have plugin loaded # output = self.run_with_output('version') # assert 'plugins: ' in output class CommonOptionsParserTest(unittest.TestCase): def test_album_option(self): parser = ui.CommonOptionsParser() assert not parser._album_flags parser.add_album_option() assert bool(parser._album_flags) assert parser.parse_args([]) == ({"album": None}, []) assert parser.parse_args(["-a"]) == ({"album": True}, []) assert parser.parse_args(["--album"]) == ({"album": True}, []) def test_path_option(self): parser = ui.CommonOptionsParser() parser.add_path_option() assert not parser._album_flags config["format_item"].set("$foo") assert parser.parse_args([]) == ({"path": None}, []) assert config["format_item"].as_str() == "$foo" assert parser.parse_args(["-p"]) == ( {"path": True, "format": "$path"}, [], ) assert parser.parse_args(["--path"]) == ( {"path": True, "format": "$path"}, [], ) assert config["format_item"].as_str() == "$path" assert config["format_album"].as_str() == "$path" def test_format_option(self): parser = ui.CommonOptionsParser() parser.add_format_option() assert not parser._album_flags config["format_item"].set("$foo") assert parser.parse_args([]) == ({"format": None}, []) assert config["format_item"].as_str() == "$foo" assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) assert parser.parse_args(["--format", "$baz"]) == ( {"format": "$baz"}, [], ) assert config["format_item"].as_str() == "$baz" assert config["format_album"].as_str() == "$baz" def test_format_option_with_target(self): with pytest.raises(KeyError): ui.CommonOptionsParser().add_format_option(target="thingy") parser = ui.CommonOptionsParser() parser.add_format_option(target="item") config["format_item"].set("$item") config["format_album"].set("$album") assert parser.parse_args(["-f", "$bar"]) == ({"format": "$bar"}, []) assert config["format_item"].as_str() == "$bar" assert config["format_album"].as_str() == "$album" def test_format_option_with_album(self): parser = ui.CommonOptionsParser() parser.add_album_option() parser.add_format_option() config["format_item"].set("$item") config["format_album"].set("$album") parser.parse_args(["-f", "$bar"]) assert config["format_item"].as_str() == "$bar" assert config["format_album"].as_str() == "$album" parser.parse_args(["-a", "-f", "$foo"]) assert config["format_item"].as_str() == "$bar" assert config["format_album"].as_str() == "$foo" parser.parse_args(["-f", "$foo2", "-a"]) assert config["format_album"].as_str() == "$foo2" def test_add_all_common_options(self): parser = ui.CommonOptionsParser() parser.add_all_common_options() assert parser.parse_args([]) == ( {"album": None, "path": None, "format": None}, [], ) class EncodingTest(unittest.TestCase): """Tests for the `terminal_encoding` config option and our `_in_encoding` and `_out_encoding` utility functions. """ def out_encoding_overridden(self): config["terminal_encoding"] = "fake_encoding" assert ui._out_encoding() == "fake_encoding" def in_encoding_overridden(self): config["terminal_encoding"] = "fake_encoding" assert ui._in_encoding() == "fake_encoding" def out_encoding_default_utf8(self): with patch("sys.stdout") as stdout: stdout.encoding = None assert ui._out_encoding() == "utf-8" def in_encoding_default_utf8(self): with patch("sys.stdin") as stdin: stdin.encoding = None assert ui._in_encoding() == "utf-8" ================================================ FILE: test/ui/test_ui_importer.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the TerminalImportSession. The tests are the same as in the test_importer module. But here the test importer inherits from ``TerminalImportSession``. So we test this class, too. """ from beets.test.helper import TerminalImportMixin from test import test_importer class NonAutotaggedImportTest( TerminalImportMixin, test_importer.NonAutotaggedImportTest ): pass class ImportTest(TerminalImportMixin, test_importer.ImportTest): pass class ImportSingletonTest( TerminalImportMixin, test_importer.ImportSingletonTest ): pass class ImportTracksTest(TerminalImportMixin, test_importer.ImportTracksTest): pass class ImportCompilationTest( TerminalImportMixin, test_importer.ImportCompilationTest ): pass class ImportExistingTest(TerminalImportMixin, test_importer.ImportExistingTest): pass class ChooseCandidateTest( TerminalImportMixin, test_importer.ChooseCandidateTest ): pass class GroupAlbumsImportTest( TerminalImportMixin, test_importer.GroupAlbumsImportTest ): pass class GlobalGroupAlbumsImportTest( TerminalImportMixin, test_importer.GlobalGroupAlbumsImportTest ): pass ================================================ FILE: test/ui/test_ui_init.py ================================================ # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test module for file ui/__init__.py""" import os import shutil import unittest from copy import deepcopy from random import random from beets import config, ui from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin class InputMethodsTest(IOMixin, unittest.TestCase): def _print_helper(self, s): print(s) def _print_helper2(self, s, prefix): print(prefix, s) def test_input_select_objects(self): full_items = ["1", "2", "3", "4", "5"] # Test no self.io.addinput("n") items = ui.input_select_objects( "Prompt", full_items, self._print_helper ) assert items == [] # Test yes self.io.addinput("y") items = ui.input_select_objects( "Prompt", full_items, self._print_helper ) assert items == full_items # Test selective 1 self.io.addinput("s") self.io.addinput("n") self.io.addinput("y") self.io.addinput("n") self.io.addinput("y") self.io.addinput("n") items = ui.input_select_objects( "Prompt", full_items, self._print_helper ) assert items == ["2", "4"] # Test selective 2 self.io.addinput("s") self.io.addinput("y") self.io.addinput("y") self.io.addinput("n") self.io.addinput("y") self.io.addinput("n") items = ui.input_select_objects( "Prompt", full_items, lambda s: self._print_helper2(s, "Prefix") ) assert items == ["1", "2", "4"] # Test selective 3 self.io.addinput("s") self.io.addinput("y") self.io.addinput("n") self.io.addinput("y") self.io.addinput("q") items = ui.input_select_objects( "Prompt", full_items, self._print_helper ) assert items == ["1", "3"] class ParentalDirCreation(IOMixin, BeetsTestCase): def test_create_yes(self): non_exist_path = _common.os.fsdecode( os.path.join(self.temp_dir, b"nonexist", str(random()).encode()) ) # Deepcopy instead of recovering because exceptions might # occur; wish I can use a golang defer here. test_config = deepcopy(config) test_config["library"] = non_exist_path self.io.addinput("y") lib = ui._open_library(test_config) lib._close() def test_create_no(self): non_exist_path_parent = _common.os.fsdecode( os.path.join(self.temp_dir, b"nonexist") ) non_exist_path = _common.os.fsdecode( os.path.join(non_exist_path_parent.encode(), str(random()).encode()) ) test_config = deepcopy(config) test_config["library"] = non_exist_path self.io.addinput("n") try: lib = ui._open_library(test_config) except ui.UserError: if os.path.exists(non_exist_path_parent): shutil.rmtree(non_exist_path_parent) raise OSError("Parent directories should not be created.") else: if lib: lib._close() raise OSError("Parent directories should not be created.") ================================================ FILE: test/util/test_color.py ================================================ from unittest import TestCase from beets.util.color import color_split, uncolorize class ColorTestCase(TestCase): def test_uncolorize(self): assert "test" == uncolorize("test") txt = uncolorize("\x1b[31mtest\x1b[39;49;00m") assert "test" == txt txt = uncolorize("\x1b[31mtest\x1b[39;49;00m test") assert "test test" == txt txt = uncolorize("\x1b[31mtest\x1b[39;49;00mtest") assert "testtest" == txt txt = uncolorize("test \x1b[31mtest\x1b[39;49;00m test") assert "test test test" == txt def test_color_split(self): exp = ("test", "") res = color_split("test", 5) assert exp == res exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") res = color_split("\x1b[31mtest\x1b[39;49;00m", 3) assert exp == res ================================================ FILE: test/util/test_config.py ================================================ import pytest from beets.util.config import sanitize_choices, sanitize_pairs @pytest.mark.parametrize( "input_choices, valid_choices, expected", [ (["A", "Z"], ("A", "B"), ["A"]), (["A", "A"], ("A"), ["A"]), (["D", "*", "A"], ("A", "B", "C", "D"), ["D", "B", "C", "A"]), ], ) def test_sanitize_choices(input_choices, valid_choices, expected): assert sanitize_choices(input_choices, valid_choices) == expected def test_sanitize_pairs(): assert sanitize_pairs( [ ("foo", "baz bar"), ("foo", "baz bar"), ("key", "*"), ("*", "*"), ("discard", "bye"), ], [ ("foo", "bar"), ("foo", "baz"), ("foo", "foobar"), ("key", "value"), ], ) == [ ("foo", "baz"), ("foo", "bar"), ("key", "value"), ("foo", "foobar"), ] ================================================ FILE: test/util/test_diff.py ================================================ from textwrap import dedent import pytest from beets.library import Item from beets.util.diff import _field_diff p = pytest.param class TestFieldDiff: @pytest.fixture(autouse=True) def configure_color(self, config, color): config["ui"]["color"] = color @pytest.fixture(autouse=True) def patch_colorize(self, monkeypatch): """Patch to return a deterministic string format instead of ANSI codes.""" monkeypatch.setattr( "beets.util.color._colorize", lambda color_name, text: f"[{color_name}]{text}[/]", ) @staticmethod def diff_fmt(old, new): return f"[text_diff_removed]{old}[/] -> [text_diff_added]{new}[/]" @pytest.mark.parametrize( "old_data, new_data, field, expected_diff", [ p({"title": "foo"}, {"title": "foo"}, "title", None, id="no_change"), p({"bpm": 120.0}, {"bpm": 120.005}, "bpm", None, id="float_close_enough"), p({"bpm": 120.0}, {"bpm": 121.0}, "bpm", f"bpm: {diff_fmt('120', '121')}", id="float_changed"), p({"title": "foo"}, {"title": "bar"}, "title", f"title: {diff_fmt('foo', 'bar')}", id="string_full_replace"), p({"title": "prefix foo"}, {"title": "prefix bar"}, "title", "title: prefix [text_diff_removed]foo[/] -> prefix [text_diff_added]bar[/]", id="string_partial_change"), p({"year": 2000}, {"year": 2001}, "year", f"year: {diff_fmt('2000', '2001')}", id="int_changed"), p({}, {"artist": "Artist"}, "artist", "artist: -> [text_diff_added]Artist[/]", id="field_added"), p({"artist": "Artist"}, {}, "artist", "artist: [text_diff_removed]Artist[/] -> ", id="field_removed"), p({"track": 1}, {"track": 2}, "track", f"track: {diff_fmt('01', '02')}", id="formatted_value_changed"), p({"mb_trackid": None}, {"mb_trackid": "1234"}, "mb_trackid", "mb_trackid: -> [text_diff_added]1234[/]", id="none_to_value"), p({}, {"new_flex": "foo"}, "new_flex", "[text_diff_added]new_flex: foo[/]", id="flex_field_added"), p({"old_flex": "foo"}, {}, "old_flex", "[text_diff_removed]old_flex: foo[/]", id="flex_field_removed"), p({"albumtypes": ["album", "ep"]}, {"albumtypes": ["ep", "album"]}, "albumtypes", None, id="multi_value_unchanged"), p( {"albumtypes": ["ep"]}, {"albumtypes": ["album", "compilation"]}, "albumtypes", dedent(""" albumtypes: [text_diff_removed] - ep[/] [text_diff_added] + album[/] [text_diff_added] + compilation[/] """).strip(), id="multi_value_changed" ), ], ) # fmt: skip @pytest.mark.parametrize("color", [True], ids=["color_enabled"]) def test_field_diff_colors(self, old_data, new_data, field, expected_diff): old_item = Item(**old_data) new_item = Item(**new_data) diff = _field_diff(field, old_item.formatted(), new_item.formatted()) assert diff == expected_diff @pytest.mark.parametrize("color", [False], ids=["color_disabled"]) def test_field_diff_no_color(self): old_item = Item(title="foo") new_item = Item(title="bar") diff = _field_diff("title", old_item.formatted(), new_item.formatted()) assert diff == "title: foo -> bar" ================================================ FILE: test/util/test_id_extractors.py ================================================ from typing import NamedTuple import pytest from beets.util.id_extractors import extract_release_id @pytest.mark.parametrize( "source, id_string, expected", [ ("spotify", "39WqpoPgZxygo6YQjehLJJ", "39WqpoPgZxygo6YQjehLJJ"), ("spotify", "blah blah", None), ("spotify", "https://open.spotify.com/album/39WqpoPgZxygo6YQjehLJJ", "39WqpoPgZxygo6YQjehLJJ"), ("deezer", "176356382", "176356382"), ("deezer", "blah blah", None), ("deezer", "https://www.deezer.com/album/176356382", "176356382"), ("beatport", "3089651", "3089651"), ("beatport", "blah blah", None), ("beatport", "https://www.beatport.com/release/album-name/3089651", "3089651"), ("discogs", "http://www.discogs.com/G%C3%BCnther-Lause-Meru-Ep/release/4354798", "4354798"), ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-Lause-Meru-Ep", "4354798"), ("discogs", "http://www.discogs.com/G%C3%BCnther-4354798Lause-Meru-Ep/release/4354798", "4354798"), ("discogs", "http://www.discogs.com/release/4354798-G%C3%BCnther-4354798Lause-Meru-Ep/", "4354798"), ("discogs", "[r4354798]", "4354798"), ("discogs", "r4354798", "4354798"), ("discogs", "4354798", "4354798"), ("discogs", "yet-another-metadata-provider.org/foo/12345", None), ("discogs", "005b84a0-ecd6-39f1-b2f6-6eb48756b268", None), ("musicbrainz", "28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), ("musicbrainz", "blah blah", None), ("musicbrainz", "https://musicbrainz.org/entity/28e32c71-1450-463e-92bf-e0a46446fc11", "28e32c71-1450-463e-92bf-e0a46446fc11"), ("bandcamp", "https://nameofartist.bandcamp.com/album/nameofalbum", "https://nameofartist.bandcamp.com/album/nameofalbum"), ], ) # fmt: skip def test_extract_release_id(source, id_string, expected): assert extract_release_id(source, id_string) == expected class SourceWithURL(NamedTuple): source: str url: str source_with_urls = [ SourceWithURL("spotify", "https://open.spotify.com/album/39WqpoPgZxygo6YQjehLJJ"), SourceWithURL("deezer", "https://www.deezer.com/album/176356382"), SourceWithURL("beatport", "https://www.beatport.com/release/album-name/3089651"), SourceWithURL("discogs", "http://www.discogs.com/G%C3%BCnther-Lause-Meru-Ep/release/4354798"), SourceWithURL("musicbrainz", "https://musicbrainz.org/entity/28e32c71-1450-463e-92bf-e0a46446fc11"), ] # fmt: skip @pytest.mark.parametrize("source", [s.source for s in source_with_urls]) @pytest.mark.parametrize("source_with_url", source_with_urls) def test_match_source_url(source, source_with_url): if source == source_with_url.source: assert extract_release_id(source, source_with_url.url) else: assert not extract_release_id(source, source_with_url.url), ( f"Source {source} pattern should not match {source_with_url.source} URL" ) ================================================ FILE: test/util/test_layout.py ================================================ from unittest import TestCase from beets.util.layout import split_into_lines class LayoutTestCase(TestCase): def test_split_into_lines(self): # Test uncolored text txt = split_into_lines("test test test", 5, 5) assert txt == ["test", "test", "test"] # Test multiple colored texts colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 split_txt = [ "\x1b[31mtest\x1b[39;49;00m", "\x1b[31mtest\x1b[39;49;00m", "\x1b[31mtest\x1b[39;49;00m", ] txt = split_into_lines(colored_text, 5, 5) assert txt == split_txt # Test single color, multi space text colored_text = "\x1b[31m test test test \x1b[39;49;00m" txt = split_into_lines(colored_text, 5, 5) assert txt == split_txt # Test single color, different spacing colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" # ToDo: fix color_len to handle mid-text color escapes, and thus # split colored texts over newlines (potentially with dashes?) split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] txt = split_into_lines(colored_text, 5, 5) assert txt == split_txt ================================================ FILE: test/util/test_lyrics.py ================================================ import textwrap from beets.util.lyrics import Lyrics class TestLyrics: def test_instrumental_lyrics(self): lyrics = Lyrics( "[Instrumental]", "lrclib", url="https://lrclib.net/api/1" ) assert lyrics.full_text == "[Instrumental]" assert lyrics.backend == "lrclib" assert lyrics.url == "https://lrclib.net/api/1" assert lyrics.language is None assert lyrics.translation_language is None def test_from_legacy_text(self, is_importable): text = textwrap.dedent(""" [00:00.00] Some synced lyrics / Quelques paroles synchronisées [00:00.50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées Source: https://lrclib.net/api/1/""") lyrics = Lyrics.from_legacy_text(text) assert lyrics.full_text == textwrap.dedent( """ [00:00.00] Some synced lyrics / Quelques paroles synchronisées [00:00.50] [00:01.00] Some more synced lyrics / Quelques paroles plus synchronisées""" ) assert lyrics.backend == "lrclib" assert lyrics.url == "https://lrclib.net/api/1/" langdetect_available = is_importable("langdetect") assert lyrics.language == ("EN" if langdetect_available else None) assert lyrics.translation_language == ( "FR" if langdetect_available else None ) ================================================ FILE: test/util/test_units.py ================================================ import pytest from beets.util.units import human_bytes, human_seconds @pytest.mark.parametrize( "input_bytes,expected", [ (0, "0.0 B"), (30, "30.0 B"), (pow(2, 10), "1.0 KiB"), (pow(2, 20), "1.0 MiB"), (pow(2, 30), "1.0 GiB"), (pow(2, 40), "1.0 TiB"), (pow(2, 50), "1.0 PiB"), (pow(2, 60), "1.0 EiB"), (pow(2, 70), "1.0 ZiB"), (pow(2, 80), "1.0 YiB"), (pow(2, 90), "1.0 HiB"), (pow(2, 100), "big"), ], ) def test_human_bytes(input_bytes, expected): assert human_bytes(input_bytes) == expected @pytest.mark.parametrize( "input_seconds,expected", [ (0, "0.0 seconds"), (30, "30.0 seconds"), (60, "1.0 minutes"), (90, "1.5 minutes"), (125, "2.1 minutes"), (3600, "1.0 hours"), (86400, "1.0 days"), (604800, "1.0 weeks"), (31449600, "1.0 years"), (314496000, "1.0 decades"), ], ) def test_human_seconds(input_seconds, expected): assert human_seconds(input_seconds) == expected