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
]*>").sub, "\n\n")
#: a single new line between paragraphs on separate lines
#: (paroles.net, sweetslyrics.com, lacoccinelle.net)
merge_lines = partial(re.compile(r"