Compare commits

...

216 commits

Author SHA1 Message Date
Guy Bloom
2bd77b9895
Fix convert --format with never_convert_lossy_files (#6171)
## Description

Fixes #5625 

When `convert.never_convert_lossy_files` is enabled, `beet convert` was
ignoring the explicit `--format` option and just copying the lossy files
without
transcoding them. For example:

- `beet convert format:mp3 --format opus`

would still produce MP3 files instead of OPUS.

Change:

- Allows to override options `never_convert_lossy_files`, `max_bitrate`
or `no_convert` for `beet convert` as well as trying to convert to the
same format as existing already with a new option `--force`. That way,
for example lossy files selected by the query are transcoded to the
requested format anyway.
- Keeps existing behavior for automatic conversion on import (no CLI
override there).
- Adds tests to cover checking whether `--force` correctly overrides
settings or CLI options.
- Documents the behavior in the convert plugin docs

Co-authored-by: J0J0 Todos <jojo@peek-a-boo.at>
2025-12-03 22:48:41 +01:00
Sebastian Mohr
53931279a3
docs: fix link to plugin development docs (#6198)
Fixes the link to the plugin development documentation.
2025-12-02 11:45:05 +01:00
Robin Bowes
fdaebc653a docs: Fix link to plugin development docs 2025-12-02 11:40:18 +01:00
Sebastian Mohr
ca7e959f5b
Sanitize log messages by removing control characters (#6199)
This pull request addresses an issue where control characters in log
messages could halt beets execution entirely. The fix implements
sanitization of log messages by removing C0 and C1 control characters
before they reach the terminal.
2025-12-02 11:32:00 +01:00
Anton Bobov
67e668d81f
fix: Sanitize log messages by removing control characters
Added regex pattern to strip C0/C1 control characters (excluding useful
whitespace) from log messages before terminal output. This prevents
disruptive/malicious control sequences from affecting terminal
rendering.
2025-12-02 15:27:24 +05:00
Šarūnas Nejus
6abb901b6b
Add deprecation warning for musicbrainz.enabled but use it to load the plugin, centralise deprecations handling (#6127)
Fixes: #6121

This PR introduces a centralized deprecation system and adjusts
`musicbrainz` plugin loading to properly handle the deprecated
`musicbrainz.enabled` configuration option.

#### MusicBrainz

- Added deprecation warnings for the `musicbrainz.enabled` configuration
option:
- When set to `true`, warns users to explicitly add `musicbrainz` to
their `plugins` configuration and adds it if not already present
- When set to `false`, warns users and adds the plugin to
`disabled_plugins` (list
    received by the `--disable-plugins` flag)

#### Deprecations

- Created new `beets/util/deprecation.py` module with standardized
deprecation helpers:
  - `deprecate_for_user()` - logs warnings visible to end users
- `deprecate_for_maintainers()` - emits `DeprecationWarning` for
developers
- `deprecate_imports()` - handles deprecated module imports with
automatic version calculation
- `_format_message()` - generates consistent deprecation messages that
auto-calculate next major version

- Migrated all deprecation handling to use the new centralized
functions:
  - Replaced inline `warnings.warn()` calls throughout codebase
- Updated `deprecate_imports()` signature to remove explicit `version`
parameter
- Converted user-facing deprecation warnings in plugins to use
logger-based `deprecate_for_user()`
2025-12-02 01:56:00 +00:00
Šarūnas Nejus
05430f312c
Move PromptChoice to beets.util module
And update imports that have been raising the deprecation warning.
2025-12-02 01:51:14 +00:00
Šarūnas Nejus
dd72704d3d
Do not force load musicbrainz, add a test to show the behaviour 2025-11-30 07:42:21 +00:00
Šarūnas Nejus
b643fc4ce5
Do not show a warning to users that have musicbrainz disabled 2025-11-30 07:42:19 +00:00
Šarūnas Nejus
3bb068a675
Warn users of deprecated musicbrainz.enabled option 2025-11-30 07:02:46 +00:00
Šarūnas Nejus
9f7cb8dbe4
Load musicbrainz implicitly and supply a deprecation warning 2025-11-30 07:02:46 +00:00
Šarūnas Nejus
5a3ecf6842
Add deprecate_for_user function 2025-11-30 07:02:46 +00:00
Šarūnas Nejus
39288637b9
Centralise warnings for maintainers into deprecate_for_maintainers 2025-11-30 07:02:46 +00:00
Šarūnas Nejus
c79cad4ed1
Move deprecate_imports to beets.util.deprecation 2025-11-30 07:02:46 +00:00
Šarūnas Nejus
95b3364361
reflink() doesn't take Path parameters (#6186)
Fix `test_successful_reflink`, by passing the right kinds of parameters.

This was failing inside the reflink package:

```
/usr/lib/python3/dist-packages/reflink/reflink.py:34: in reflink
    backend.clone(oldpath, newpath)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

oldpath = PosixPath('/tmp/tmpx3jirmhp/testfile')
newpath = PosixPath('/tmp/tmpx3jirmhp/testfile.dest')

    def clone(oldpath, newpath):
        if isinstance(oldpath, unicode):
            oldpath = oldpath.encode(sys.getfilesystemencoding())
        if isinstance(newpath, unicode):
            newpath = newpath.encode(sys.getfilesystemencoding())

>       newpath_c = ffi.new('char[]', newpath)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: expected new array length or list/tuple/str, not PosixPath
```
2025-11-30 05:42:38 +00:00
Šarūnas Nejus
26fde1ebf0
Merge branch 'master' into fix-reflink 2025-11-30 05:37:48 +00:00
henry
cb0d15ff84
Remove gmusic plugin (#6192)
Sometimes it is time to let go of old things:
This PR removes the old gmusic plugin and all related docs. 

---

The google play music service was shutdown in 2020 and already
deprecated in beets 1.6.0.
2025-11-28 12:19:10 -08:00
Sebastian Mohr
5cc7dcfce7 Sometimes it is time to let go of old things:
This removes old references and docs for the old gmusic plugin.
2025-11-27 21:58:29 +01:00
henry
b4f0dbf53b
Fix recursion in inline plugin when item_fields shadow DB fields (#6115) (#6174)
## Description

Fixes [#6115](https://github.com/beetbox/beets/issues/6115).

When an inline field definition shadows a built-in database field (e.g.,
redefining `track_no` in `item_fields`), the inline plugin evaluates the
field template by constructing a dictionary of all item values.
Previously, this triggered unbounded recursion because `_dict_for(obj)`
re-entered `__getitem__` for the same key while evaluating the computed
field.

This PR adds a per-object, per-key evaluation guard to prevent re-entry
when the same inline field is accessed during expression evaluation.
This resolves the recursion error while preserving normal computed-field
behavior.

A regression test
(`TestInlineRecursion.test_no_recursion_when_inline_shadows_fixed_field`)
verifies that `$track_no` evaluates correctly (`'01'`) when shadowed.

## To Do

- [x] ~Documentation.~
- [x] ~Changelog.~
- [x] Tests.
2025-11-25 16:25:25 -08:00
Gabriel Push
cd8e466a46 Updated changelog documentation 2025-11-25 19:18:10 -05:00
Gabriel Push
51164024c0 Fixed unit tests import 2025-11-25 18:41:31 -05:00
Gabriel Push
c59134bdb6 Fixed unit tests import 2025-11-25 18:38:09 -05:00
Gabriel Push
e827d43213 Fixed unit tests 2025-11-25 18:35:03 -05:00
Gabriel Push
eb11537328
Merge branch 'master' into gabepush-test-fix 2025-11-25 18:16:08 -05:00
Gabriel Push
13f95dcf3a Added documentation header 2025-11-25 18:15:18 -05:00
henry
b902352139
New Plugin: Titlecase (#6133)
This plugin aims to address the shortcomings of the %title function, as
brought up in issues #152, #3298 and an initial look to improvement with
#3411. It supplies a new string format command, `%titlecase` which
doesn't interfere with any prior expected behavior of the `%title`
format command.

It also adds the ability to apply titlecase logic to metadata fields
that a user selects, which is useful if you, like me, are looking for
stylistic consistency and the minor stylistic differences between
Musizbrainz, Discogs, Deezer etc, with title case are slightly
infuriating.

This will add an optional dependency of
[titlecase](https://pypi.org/project/titlecase/), which allows the
titlecase core logic to be externally maintained.

If there's not enough draw to have this as a core plugin, I can also
spin this into an independent one, but it seemed like a recurring theme
that the %title string format didn't really behave as expected, and I
wanted my metadata to match too.

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
- [x] Tests. - Not 100% coverage, but didn't see a lot of other plugins
with testing for import stages.
2025-11-23 10:34:05 -08:00
Stefano Rivera
4a17901c1d reflink() doesn't take Path parameters
Fix `test_successful_reflink`, by passing the right kinds of parameters.

This was failing inside the reflink package:

```
/usr/lib/python3/dist-packages/reflink/reflink.py:34: in reflink
    backend.clone(oldpath, newpath)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

oldpath = PosixPath('/tmp/tmpx3jirmhp/testfile')
newpath = PosixPath('/tmp/tmpx3jirmhp/testfile.dest')

    def clone(oldpath, newpath):
        if isinstance(oldpath, unicode):
            oldpath = oldpath.encode(sys.getfilesystemencoding())
        if isinstance(newpath, unicode):
            newpath = newpath.encode(sys.getfilesystemencoding())

>       newpath_c = ffi.new('char[]', newpath)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: expected new array length or list/tuple/str, not PosixPath
```
2025-11-23 13:50:57 -04:00
Sebastian Mohr
d446e10fb0
Add album template value in ftintitle plugin (#6164)
## Description

I was hoping to use the functionality from `ftintitle` to set the path's
album artist as the main artist, but that wasn't possible, so I added a
template value `album_artist_no_feat`.
2025-11-21 18:42:06 +01:00
Sebastian Mohr
ba18ee2f14 Added comment for deprecation in 3.0.0. 2025-11-21 18:31:59 +01:00
asardaes
be0b71043c Revert "Remove class variables for template fields and funcs"
This reverts commit a7033fe63b.
2025-11-21 18:31:59 +01:00
asardaes
23a19e9409 Remove class variables for template fields and funcs 2025-11-21 18:31:59 +01:00
asardaes
2eff2d25f5 Improve typing for template fields and funcs 2025-11-21 18:31:59 +01:00
asardaes
9c37f94171 Add album template value in ftintitle plugin 2025-11-21 18:31:59 +01:00
Gabriel Push
c0ca045c20
Merge branch 'master' into gabepush-test-fix 2025-11-20 16:15:59 -05:00
Gabriel Push
ba45fedde5 Fix inline recursion test formatting 2025-11-20 16:09:01 -05:00
Gabriel Push
aced802c56 Fix recursion in inline plugin when item_fields shadow DB fields (#6115) 2025-11-20 15:57:22 -05:00
Sebastian Mohr
f79c125d15
Catch ValueError when beetsplug.bpd cannot be imported (#6170)
Catch ValueError when setting gst required version

`pytest.importorskip` is used to catch the case when beetsplug.bpd cannot
be imported. On macOS, the `gi` module was able to be imported, but when
trying to specify `gi.require_version`, a `ValueError` is raised about
Gst being unavailable. pytest does not catch this `ValueError` during
`importskip` as it is not an `ImportError`, and thus the test suite
errors during the test collection phase.

With this change, we catch the ValueError, and re-raise it as an
`ImportError` and pytest gracefully skips those tests.

Fixes #3324
2025-11-19 12:49:07 +01:00
Ognyan Moore
aa2dc9005f
Catch ValueError when setting gst required version
pytest.importskip is used to catch the case when beetsplug.bpd cannot be
imported. On macOS, the gi module was able to be imported, but when
trying to specify `gi.require_version`, a ValueError is raised about
Gst being unavailable. pytest does not catch this ValueError during
importskip as it is not an ImportError, and thus the test suite errors
during the test collection phase.

With this change, we catch the ValueError, and re-raise it as an
ImportError and pytest gracefully skips those tests.
2025-11-19 14:43:30 +03:00
Sebastian Mohr
88ca0ce1fb
Plugins/web: fix endpoints /…/values/… (#6158)
Following #4709 and #5447, the web plugin used single-quotes (ie. string
litteral) in the SQL query for table columns.

Thus, for instance, the query `GET /item/values/albumartist` would
return the litteral "albumartist" instead of a list of unique album
artists.

This prevents the Mopidy beets integration from working, returning the
single artist "albumartist".
2025-11-17 10:21:31 +01:00
Théophile Bastian
189fedb008 Web plugin: add type hint for g.lib 2025-11-15 21:02:43 +01:00
Théophile Bastian
666c412b0e plugins/web: fix endpoints /…/values/…
Following #4709 and #5447, the web plugin used single-quotes (ie. string
litteral) in the SQL query for table columns. Thus, for instance, the query
`GET /item/values/albumartist` would return the litteral "albumartist"
instead of a list of unique album artists.
2025-11-15 21:02:38 +01:00
Sebastian Mohr
07445fdd07
Fix import --from-logfile (#6161)
Fixes "none of the paths are importable" error that was accidentally
introduced in 4260162d4

4260162d44 (diff-e324b20657a7d6b43b8b7aeb5754b96774f5062294b5ba7f1e3062845e9e7044R1382-R1390)
2025-11-13 19:32:19 +01:00
J0J0 Todos
97bc0b3b8c Changelog for #6161 2025-11-13 19:26:18 +01:00
J0J0 Todos
2ef77852b7 Fix import --from-logfile
Fixes "none of the paths are importable" error with any valid import log
file that was accidentally introduced in commit 4260162d4
2025-11-13 19:26:18 +01:00
Šarūnas Nejus
e326aafac0
Allow selecting either tags or genres in the includes, defaulting to genres (#5874)
Genres is a filtered list based on what musicbrainz considers a genre,
tags are all the user-submitted tags. [1]

1.
https://musicbrainz.org/doc/MusicBrainz_API#:~:text=Since%20genres%20are,!).
2025-11-12 21:40:02 +00:00
Aidan Epstein
672bf0bf41 Add tests. 2025-11-11 17:08:46 -08:00
Aidan Epstein
d7636fb0c3 Apply suggestions from code review
Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
2025-11-11 13:18:51 -08:00
Aidan Epstein
9e7d5debdc Allow selecting either tags or genres in the includes, defaulting to genres
Genres is a filtered list based on what musicbrainz considers a genre,
tags are all the user-submitted tags. [1]

1. https://musicbrainz.org/doc/MusicBrainz_API#:~:text=Since%20genres%20are,!).

Also apply suggestions from code review

Co-authored-by: Šarūnas Nejus <snejus@protonmail.com>
2025-11-11 20:01:37 +00:00
Sebastian Mohr
f3da80e512
FIX: Dereference symlinks before hardlinking (#5684)
When creating a hardlink, either during import or `beet convert`, if the
origin of the hardlink was a symlink, that symlink used to be directly
copied. This could create broken symlinks if the origin symlink was
relative, and in either case, probably wasn't the user's desired
behavior.

This change de-references all symlinks before creating a hardlink, such
that the end result is a normal file with the same inode as the original
file. See #5676 for more discussion about the original issue.

Fixes #5676
2025-11-11 17:07:47 +01:00
Emi Katagiri-Simpson
29a5b06f67
Merge remote-tracking branch 'upstream/master' into dereference-symlinks-while-hardlinking 2025-11-11 07:58:10 -05:00
Šarūnas Nejus
3a72d85c5e
Drop support for Python 3.9 (#6144)
Drop support for Python 3.9 and pyupgrade the codebase.

Dependency upgrades:

<img width="644" height="1017" alt="image"
src="https://github.com/user-attachments/assets/e5be110b-66fb-4373-8413-e09a56ba54bc"
/>
2025-11-11 04:09:46 +00:00
Šarūnas Nejus
bef249e616
Fix format-docs command 2025-11-11 04:03:52 +00:00
Šarūnas Nejus
881549e83c
Enable all pyupgrade lint rules 2025-11-08 12:09:52 +00:00
Šarūnas Nejus
ffa70acad9
Ignore pyupgrade blame 2025-11-08 12:09:52 +00:00
Šarūnas Nejus
d486885af3
pyupgrade Python 3.10 2025-11-08 12:09:52 +00:00
Šarūnas Nejus
dc33932871
Update python version references 2025-11-08 12:09:52 +00:00
Šarūnas Nejus
a7830bebae
Update python requirement and dependencies 2025-11-08 11:55:20 +00:00
Šarūnas Nejus
d64efbb6c1
Upgrade deps before upgrade 2025-11-08 11:55:20 +00:00
Emi Katagiri-Simpson
b405d2fded
Migrate os calls to pathlib calls in hardlink util function
See discussion here: https://github.com/beetbox/beets/pull/5684#discussion_r2502432781
2025-11-07 15:05:56 -05:00
henry
29b9958626
BUG: Wrong path edited when running config -e (#5685)
As per #5652, `beet --config <path> config -e` edited the default config
path, even though that's not the config that would be used by beets.

It seems like this was the result of a deliberate short-circuit in
[`_raw_main()`](c2de6feada/beets/ui/__init__.py (L1832)).
The short-circuit prevents malformed configs from causing a crash before
opening the editor, but also prevents the setup function from loading
the custom config at all.

The solution used here is to just expose the CLI options to
`edit_config()`, so that it can use the custom config path if its set. I
also suspect that the branch in
[`config_func()`](c2de6feada/beets/ui/commands.py (L2354))
which is getting short circuited is actually unreachable, but I left it
in with a note just in case.

Fixes #5652

## To Do

- [x] ~~Documentation~~ (N/A)
- [X] Changelog
- [X] Tests
2025-11-07 11:51:26 -08:00
henry
81f10729e1
Merge branch 'master' into edit-custom-config 2025-11-07 11:40:50 -08:00
Sebastian Mohr
7cca07d2c3
Accept lyrics source as a string (#6149)
## Description

Fixes #5962.

The fix was shared in the issue. Now it uses ```as_str_seq``` similarly
to other plugins.
2025-11-07 10:40:35 +01:00
Ratiq Narwal
26a8e164d5 Remove newline character between list points 2025-11-06 18:10:48 -08:00
Ratiq Narwal
f77c03ed90 Remove unnecessary space 2025-11-06 17:58:25 -08:00
Ratiq Narwal
60ad6dc503 Fix changelog formatting 2025-11-06 17:41:21 -08:00
Emi Katagiri-Simpson
0e74605efd
Merge remote-tracking branch 'upstream/master' into dereference-symlinks-while-hardlinking 2025-11-06 20:30:40 -05:00
Ratiq Narwal
a7becf8490 Improve changelog 2025-11-06 17:29:33 -08:00
Ratiq Narwal
e9afe069bc Accept lyrics source as a string 2025-11-06 17:19:27 -08:00
Emi Katagiri-Simpson
86a74970f9
Merge remote-tracking branch 'upstream/master' into HEAD 2025-11-06 20:11:57 -05:00
Sebastian Mohr
61a4c737ee
Refactor ui/commands.py monolith into modular structure (#6119)
## Description

This one’s a big one 🎣 Proceed with care and a bit
of time ;)

The `ui/commands.py` file had grown into an unwieldy monolith (2000+
lines) over time, so this PR breaks it apart into a modular structure
i.e. **one file per command**, plus some cleanup and reorganization
along the way.

---

###  What changed

* **Commands modularized:**
Every command (`help`, `list`, `move`, `update`, `remove`, etc.) now
lives in its own file under `ui/commands/`.
* **Support code reorganized:**
  * Utility functions moved into a separate helper module.
* `commands.py` converted into `commands/__init__.py` for better import
handling.
* The `import` command (and related helpers) moved into its own folder:
    * `importer/session.py` for import session logic
    * `importer/display.py` for display-related functions
* **Tests cleaned up:**
  * Each command’s tests now live in their own file.
  * All UI-related tests were moved into a dedicated folder for clarity.
2025-11-05 16:01:10 +01:00
Sebastian Mohr
f495a9e18d Added more descriptions to git-blame-ignore-revs file. 2025-11-05 15:54:35 +01:00
Sebastian Mohr
b3b7dc3316 Added changelog entry and git blame ignore revs. 2025-11-03 14:04:58 +01:00
Sebastian Mohr
25ae330044 refactor: moved some more imports that are only used in the commands
in their respective files. Also fixed some imports
2025-11-03 14:03:25 +01:00
Sebastian Mohr
a59e41a883 tests: move command tests into dedicated files
Moved tests related to ui into own folder.
Moved 'modify' command tests into own file.
Moved 'write' command tests into own file.
Moved 'fields' command tests into own file.
Moved 'do_query' test into own file.
Moved 'list' command tests into own file.
Moved 'remove' command tests into own file.
Moved 'move' command tests into own file.
Moved 'update' command tests into own file.
Moved 'show_change' test into test_import file.
Moved 'summarize_items' test into test_import file.
Moved 'completion' command test into own file.
2025-11-03 14:00:58 +01:00
Sebastian Mohr
59c93e7013 refactor: reorganize command modules and utils
Moved commands.py into commands/__init__.py for easier refactoring.
Moved `version` command into its own file.
Moved `help` command into its own file.
Moved `stats` command into its own file.
Moved `list` command into its own file.
Moved `config` command into its own file.
Moved `completion` command into its own file.
Moved utility functions into own file.
Moved `move` command into its own file.
Moved `fields` command into its own file.
Moved `update` command into its own file.
Moved `remove` command into its own file.
Moved `modify` command into its own file.
Moved `write` command into its own file.
Moved `import` command into its own folder, more commit following.
Moved ImportSession related functions into `importer/session.py`.
Moved import display display related functions into `importer/display.py`
Renamed import to import_ as a module cant be named import.
Fixed imports in init file.
2025-11-03 13:32:14 +01:00
Sebastian Mohr
beda6fc71b
Add mbpseudo plugin for pseudo-release proposals (#5888)
## Description
Adds the new `mbpseudo` plugin, that proactively searches for pseudo-releases during import and
adds them as candidates. Since it also depends on MusicBrainz, there are
some special considerations for the default logic (which is now a plugin
as well). However, at the very least it expects a list of desired [names
of scripts](https://en.wikipedia.org/wiki/ISO_15924) in the
configuration, for example:

```yaml
mbpseudo:
    scripts:
    - Latn
```

It will use that to search for pseudo-releases that match some of the
desired scripts, but will only do so if the input tracks match against
an official release that is not in one of the desired scripts.

## Standalone Usage

This would be the recommended approach, which involves disabling the
`musicbrainz` plugin. The `mbpseudo` plugin will manually delegate the
initial search to it. Since the data source of official releases will
still match MusicBrainz, weights are still relevant:

```yaml
mbpseudo:
    source_weight: 0.0
    scripts:
    - Latn

musicbrainz:
    source_weight: 0.1
```

A setup like that would ensure that the pseudo-releases have slightly
more preference when choosing the final proposal.

## Combined Usage

I initially thought it would be important to coexist with the
`musicbrainz` plugin when it's enabled, and reuse as much of its data as
possible to avoid redundant calls to the MusicBrainz API. I have the
impression this is not really important in the end, and maybe things
could be simplified if we decide that both plugins shouldn't coexist.

As it is right now, using both plugins at the same time would still
work, but it'll only avoid redundancy if `musicbrainz` emits its
candidates before `mbpseudo`, ~which is why I modified the
plugin-loading logic slightly to guarantee ordering. I'm not sure if you
think this could be an issue, but I think the `musicbrainz` plugin is
also used by other plugins and I can imagine it's good to guarantee the
order that is declared in the configuration?~

If the above is fulfilled, the `mbpseudo` plugin will use listeners to
intercept data emitted by the `musicbrainz` plugin and check if any of
them have pseudo-releases that might be desirable.
2025-11-03 13:10:10 +01:00
asardaes
c087851770 Prefer alias if import languages not defined 2025-11-01 13:52:14 +01:00
asardaes
040b2dd940 Add custom_tags_only mode for mbpseudo plugin 2025-11-01 13:52:14 +01:00
asardaes
cb758988ed Fix data source penalty for mbpseudo 2025-11-01 13:52:14 +01:00
asardaes
defc602310 Update docs for mbpseudo plugin 2025-11-01 13:52:11 +01:00
asardaes
160297b086 Add tests for mbpseudo plugin 2025-11-01 13:51:34 +01:00
asardaes
229651dcad Update mbpseudo implementation for beets 2.5 2025-11-01 13:51:34 +01:00
asardaes
a42cabb477 Don't use Optional 2025-11-01 13:51:34 +01:00
asardaes
ab5705f444 Reimplement mbpseudo plugin inheriting from MusicBrainzPlugin 2025-11-01 13:51:34 +01:00
asardaes
79f691832c Use Optional 2025-11-01 13:51:34 +01:00
asardaes
0d90649029 Fix linting issues 2025-11-01 13:51:34 +01:00
asardaes
f3934dc58b Add mbpseudo plugin 2025-11-01 13:51:34 +01:00
asardaes
ac0b221802 Revert "Use pseudo-release's track titles for its recordings"
This reverts commit f3ddda3a422ffbe06722215abeec63436f1a1a43.
2025-11-01 13:51:34 +01:00
asardaes
017930dd99 Use pseudo-release's track titles for its recordings 2025-11-01 13:51:34 +01:00
henry
584329e7f0
Spotify: gracefully handle deprecated audio-features API (#6138)
Spotify has deprecated many of its APIs that we are still using, wasting
calls and time on these API calls; also results in frequent rate limits.

This PR introduces a dedicated `AudioFeaturesUnavailableError` and
tracks audio feature availability with an `audio_features_available`
flag. If the audio-features endpoint returns an HTTP 403 error, raise a
new error, log a warning once, and disable further audio-features
requests for the session.

The plugin now skips attempting audio-features lookups when disabled
(avoiding repeated failed calls and rate-limit issues).

Also, update the changelog to document the behavior.


## To Do

- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
2025-10-31 18:54:49 -07:00
Alok Saboo
7724c661a4 hopefully...this works 2025-10-30 10:49:51 -04:00
Alok Saboo
447511b4c8 ruff formating 2025-10-30 10:47:07 -04:00
Alok Saboo
8305821488 more lint 2025-10-30 10:34:30 -04:00
Alok Saboo
4302ca97eb resolve sorucery issue....make it thread safe 2025-10-30 10:29:07 -04:00
Alok Saboo
e6c70f06c1 lint 2025-10-30 10:20:53 -04:00
Alok Saboo
0d11e19ecf Spotify: gracefully handle 403 from deprecated audio-features API
Add a dedicated AudioFeaturesUnavailableError and track audio-features
availability with an audio_features_available flag. If the audio-features
endpoint returns HTTP 403, raise the new error, log a warning once, and
disable further audio-features requests for the session. The plugin now
skips attempting audio-features lookups when disabled (avoiding repeated
failed calls and potential rate-limit issues).

Also update changelog to document the behavior.
2025-10-30 10:13:54 -04:00
J0J0 Todos
9608ec0925
Add new plugin ImportSource (#4748)
A new plugin that tracks the original source paths of imported media and optionally allows cleaning up those source files.
2025-10-29 08:56:54 +01:00
J0J0 Todos
02a662e923 importfeeds: Fix tests
- Use self.config instead of global config, which was interfering whith
  other plugin tests (test_importsource) when run alongside (eg in CI)
- Rename test
2025-10-29 08:50:01 +01:00
Doron Behar
e181ebeaae importsource: Add new plugin (+docs/tests/changlog) 2025-10-29 08:50:01 +01:00
Emi Katagiri-Simpson
19665cd8cf
Merge remote-tracking branch 'upstream/master' into dereference-symlinks-while-hardlinking 2025-10-28 17:13:51 -04:00
Emi Katagiri-Simpson
1e1c649398
Use already generated config path in test_edit_config_with_custom_path 2025-10-28 16:56:43 -04:00
Emi Katagiri-Simpson
1a1fcbc3bc
Merge remote-tracking branch 'upstream/master' into edit-custom-config 2025-10-28 16:55:39 -04:00
Sebastian Mohr
adc0d9e477
docs: Rewrite Handling Paths chapter (pathlib vs utils) (#6116)
## Description

Updates the docs chapter "Handling Paths" describing how to modernise
old code and intentionally includes historical details. Examples should
further guide contributors while refactoring. 

Also moved the guide from the contribution guide into the dev docs.
2025-10-28 13:02:25 +01:00
J0J0 Todos
528d5e67e5 docs: Changelog for Handling Paths move/rewrite 2025-10-28 12:56:04 +01:00
J0J0 Todos
d283a35a10 docs: Rewrite Handling Paths chapter (pathlib) 2025-10-28 12:56:04 +01:00
J0J0 Todos
f6ba5bcf01 docs: Move "Handling Paths" to "Developers" chapter 2025-10-28 12:56:04 +01:00
Šarūnas Nejus
52b102cfa8
Add support for Python 3.13 (#6132)
Fixes #5575
Fixes #5822
Fixes #6082
Fixes #6026

### Python 3.13 compatibility
- Updated `librosa` dependency from `^0.10.2.post1` to `>=0.11` where a
bug with `numpy` types is fixed.
- Updated transitive `audioread` dependency which now pulls in
`standard-aifc`, `standard-sunau`, and `audioop-lts` packages for Python
3.13 and above.

### Python 3.14 compatibility
- Python 3.14 introduced stricter requirements for input type in low
level `fnctl.ioctl` function which we used to detect the terminal width.
I replaced it with high-level, cross-platform
`shutil.get_terminal_size()`.
- I'm not adding official support yet, as I faced many issues trying to
install `librosa` dependencies on Python 3.14. It should work fine for
people that do not use `autobpm`, and it may even work for those that do
- if they have the right set of system dependencies available. We can
revise this once we drop Python 3.9 in a couple of days.
2025-10-28 10:32:38 +00:00
Šarūnas Nejus
cbd74b3167
Update confuse 2025-10-28 10:26:35 +00:00
Šarūnas Nejus
e76665bcfb
Do not support 3.14 for now, until we drop 3.9 in a couple of days 2025-10-28 10:26:34 +00:00
Šarūnas Nejus
fdc6d6e787
Revert "Try env var"
This reverts commit e30f7fbe9c.
2025-10-27 08:55:08 +00:00
Šarūnas Nejus
e30f7fbe9c
Try env var 2025-10-27 08:45:19 +00:00
Šarūnas Nejus
ec141dbfd6
Explicitly wrap partial with staticmethod for Py3.14 2025-10-27 08:23:37 +00:00
Šarūnas Nejus
77dffd551d
Add a note in the changelog 2025-10-27 08:23:37 +00:00
Šarūnas Nejus
3eb68ef830
Use cross-platform shutil.get_terminal_size to get term_width
This fixes Python 3.14 incompatibility.
2025-10-27 08:23:37 +00:00
Šarūnas Nejus
1ea3879aae
Upgrade librosa and audioread 2025-10-27 08:23:37 +00:00
J0J0 Todos
201677ae62
lastgenre: Plugin tuning log (-vvv) (#6007) 2025-10-25 17:38:42 +02:00
J0J0 Todos
4b1e5056d5 lastgenre: Document tuning log -vvv 2025-10-23 19:02:27 +02:00
J0J0 Todos
bf507cd5d4 Changelog for lastgenre tuning log #6007 2025-10-23 19:02:27 +02:00
J0J0 Todos
a8204f8cde lastgenre: -vvv tuning log helper, remove -d
Replace extended_debug config and CLI option with -vvv and add a helper
function.
2025-10-23 19:02:03 +02:00
henry
043581e0c9
Ftintitle: Continue even if albumartist and artist is the same (#6102)
## Description

This small PR allows ftintitle to process even if the artist/s in the
artist and albumartist fields are the same.

This fixes the problem with a lot of singles like [Porter Robinsons song
Shelter](https://musicbrainz.org/release/ccc261b9-e4cc-4965-81b8-7c92a5d28601)
and even [Rihanas's album
Umbrella](https://musicbrainz.org/release/60f8f1f5-485b-4637-8574-23f2bb98531f)

Without this fix the songs would end up with the feat. artist in the
artists folder-name and not the feat. in the songs filename.
Without:
`Rihanna feat. JAY‐Z\(2007) Umbrella\01 - Umbrella (radio edit).flac`
`Porter Robinson feat. Madeon\(2016) Shelter\01 - Shelter.flac`
With:
`Rihanna\(2007) Umbrella\01 - Umbrella (radio edit) feat. JAY‐Z.flac`
`Porter Robinson\(2016) Shelter\01 - Shelter feat. Madeon.flac`

I left the current way ftintitle works as the default so stuff doesn't
randomly change for users, but maybe it should is changed as the PR that
changed the ftintitle's behavour is only ~2 month old
https://github.com/beetbox/beets/pull/5943
Thoughts?

I'm also not super happy with the args name
`skip_if_artist_and_album_artists_is_the_same` so any suggestion what it
could be instead is more than welcome 😅


## To Do

<!--
- If you believe one of below checkpoints is not required for the change
you
are submitting, cross it out and check the box nonetheless to let us
know.
  For example: - [x] ~Changelog~
- Regarding the changelog, often it makes sense to add your entry only
once
reviewing is finished. That way you might prevent conflicts from other
PR's in
that file, as well as keep the chance high your description fits with
the
  latest revision of your feature/fix.
- Regarding documentation, bugfixes often don't require additions to the
docs.
- Please remove the descriptive sentences in braces from the enumeration
below,
  which helps to unclutter your PR description.
-->

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
- [x] Tests. (Very much encouraged but not strictly required.)
2025-10-20 17:52:36 -07:00
Jacob Danell
bb541e22c3 Lint the docs 2025-10-20 15:28:33 +02:00
Ember Light
00e3da1a92
Merge branch 'master' into ftintitle-continue-even-if-albumartist-and-artist-is-the-same 2025-10-20 15:24:43 +02:00
Jacob Danell
027b775fcd Change arg name 2025-10-20 15:22:27 +02:00
Šarūnas Nejus
c26c342cc1
feat(plugin/web): support for nexttrack keypress (#6085)
This PR adds support for nexttrack keypress in the web plugin.

It uses feature detection for the Media Session API, and then
instantiates a metadata object for the session, and adds the specific
action handler for the nexttrack keypress.
2025-10-20 00:37:41 +01:00
Šarūnas Nejus
99987b3f27
Merge branch 'master' into feature/web-handle-nexttrack 2025-10-20 00:31:46 +01:00
Šarūnas Nejus
b850516a88
Fix transaction context manager signature (#6112)
Pyrefly reported the contect manager signature of Transaction as
invalid, so I corrected it.
2025-10-20 00:20:01 +01:00
Konstantin
72d879cf82
Merge branch 'master' into fix-invalid-contextmanager 2025-10-19 19:05:35 +02:00
Šarūnas Nejus
59874c5734
Use Generic instead of Any for cached_class_property (#6110)
In a plugin, self.data_source currently returns Any instead of str. This
change allows `cached_classproperty` to retain the type returned by the
decorated method.
2025-10-19 14:34:59 +01:00
Konstantin
12f2a1f694 fix mypy error 2025-10-19 15:12:27 +02:00
Konstantin
d713806263 fix transaction context manager signature 2025-10-19 15:07:17 +02:00
Konstantin
b924dfcd8c
Merge branch 'master' into generic-cached-classproperty 2025-10-19 10:09:21 +02:00
Konstantin
8a24518c4c use Generic instead of Any for cached_classproperty 2025-10-19 10:06:16 +02:00
J0J0 Todos
39aadf7099
Remove duplicate changelog entry (play plugin) 2025-10-19 08:50:25 +02:00
cvx35isl
1275ccf8c1
play plugin: $playlist marker for precise control where the playlist … (#4728)
…file is placed in the command

## Description

see included doc; placing the playlist filename at the end of command
just isn't working for all players

I have this in use with `mpv`

Co-authored-by: cvx35isl <cvx35isl@users.noreply.github.com>
Co-authored-by: J0J0 Todos <2733783+JOJ0@users.noreply.github.com>
2025-10-19 08:38:20 +02:00
Šarūnas Nejus
909b9aade4
fix(github/workflows): update to checkout v5, and setup-python v6. (#6086)
* Update github/actions used by ci and lint and others to the latest
versions.
2025-10-19 02:00:05 +01:00
Martin Atukunda
3ccc91d4d4 Drop 3.13 from python-version for now. 2025-10-19 01:53:17 +01:00
Martin Atukunda
e61ecb4496 fix(github/workflows): update to checkout v5, and setup-python v6.
* also run ci against python 3.13, which is default in debian trixie.
2025-10-19 01:53:17 +01:00
Šarūnas Nejus
7742631207
Add a consistent way to document configuration (#6088)
This PR introduces a custom Sphinx extension for documenting
configuration values and updates autotagger plugins documentation to use
consistent cross-reference formatting.

The main reason I introduced it is that I found the default `confval`
directive too verbose for our use case.

**Key Changes:**

### New Sphinx Extension (`docs/extensions/conf.py`)

- Added custom `conf` domain and directive for documenting configuration
values
- Provides `.. conf::` directive for defining config options with
default value support
- Adds `:conf:` role for creating cross-references to configuration
values
- Enables standardized documentation format:
  ```
  :conf:`plugins.plugin_name:option_name`
  ```

### Documentation Updates

- Converted inline configuration references to use `:conf:` role
throughout:
- `docs/changelog.rst`: Updated references to config options across
multiple plugin sections
  - Updated configuration documentation format:
    - `docs/plugins/discogs.rst`
    - `docs/plugins/musicbrainz.rst`
    - `docs/plugins/spotify.rst`
    - `docs/plugins/deezer.rst`
- Created `docs/plugins/shared_metadata_source_config.rst` for common
metadata source plugin options (`data_source_mismatch_penalty`,
`search_limit`, `source_weight`)

**Benefits:**

- Consistent, cross-referenceable configuration documentation
- Improved navigation between related config options
- Better maintainability through shared documentation snippets
- Enhanced release notes formatting for config references


https://github.com/user-attachments/assets/f7ee29b6-577a-468d-be58-b0aa648f28d0
2025-10-19 01:51:32 +01:00
Šarūnas Nejus
d83402fc65
Add a changelog note 2025-10-19 01:46:32 +01:00
Šarūnas Nejus
9519d47d57
Convert Python 2 URLs to Python 3 2025-10-19 01:37:42 +01:00
Šarūnas Nejus
861504d5f6
Make sure conf references are converted properly in release notes 2025-10-19 01:37:41 +01:00
Šarūnas Nejus
e872351170
Add references to configuration values in the changelog 2025-10-19 01:34:33 +01:00
Šarūnas Nejus
498b14ee1d
Convert autotagger plugin docs to use conf role 2025-10-19 01:34:33 +01:00
Šarūnas Nejus
a938449b29
Add Sphinx extension for configuration value documentation
Create a custom Sphinx extension to document configuration values with
a simplified syntax. It is based on the `confval` but takes less space
when rendered. The extension provides:

- A `conf` directive for documenting individual configuration values
  with optional type and default parameters
- A `conf` role for cross-referencing configuration values
- Automatic formatting of default values in the signature
- A custom domain that handles indexing and cross-references

For example, if we have

.. conf:: search_limit
    :default: 5

We refer to this configuration option with :conf:`plugins.discogs:search_limit`.

The extension is loaded by adding the docs/extensions directory to the
Python path and registering it in the Sphinx extensions list.
2025-10-19 01:34:32 +01:00
Ember Light
ca8df30ec3 Add missing test parameter 2025-10-16 19:06:56 +02:00
Ember Light
adb5b293f0
Update docs/plugins/ftintitle.rst
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-10-16 19:01:29 +02:00
Ember Light
9b33575a70
Update docs/changelog.rst
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-10-16 19:01:17 +02:00
Ember Light
022d7625d2 Add changelog 2025-10-16 18:49:39 +02:00
Ember Light
6d2d663d3e Add documentation 2025-10-16 18:49:32 +02:00
Ember Light
f275835cd3 Add test 2025-10-16 18:49:24 +02:00
Ember Light
472aa12767 Add main functionality 2025-10-16 18:49:14 +02:00
Šarūnas Nejus
becb073aac
lastgenre: Refactor genre applying and pretend mode (#6021)
- Refactor and reduce the code required to log and apply the genre.
- Make the output a bit more user-friendly:
    - Use str(obj) to log the object using the user configured format_item / format_album values
    - Use ui.show_model_changes to show the change in genre with colors
2025-10-15 22:50:46 +01:00
Šarūnas Nejus
88011a7c65
Show genre change using show_model_changes 2025-10-15 11:14:26 +01:00
Šarūnas Nejus
0aac7315c3
lastgenre: refactor genre processing with singledispatch
Replace the log_and_pretend decorator with a more robust implementation
using singledispatchmethod. This simplifies the genre application logic
by consolidating logging and processing into dedicated methods.

Key changes:
- Remove log_and_pretend decorator in favor of explicit dispatch
- Add _fetch_and_log_genre method to centralize genre fetching and logging
- Log user-configured full object representation instead of specific
attributes
- Introduce _process singledispatchmethod with type-specific handlers
- Use LibModel type hint for broader compatibility
- Simplify command handler by removing duplicate album/item logic
- Replace manual genre application with try_sync for consistency
2025-10-15 09:55:52 +01:00
Šarūnas Nejus
ee289844ed
Add _process_album and _process_item methods 2025-10-15 09:55:52 +01:00
Šarūnas Nejus
c2d5c1f17c
Update test 2025-10-15 09:55:51 +01:00
J0J0 Todos
65f5dd579b
Add pytest-mock to poetry test dependencies group 2025-10-15 09:55:33 +01:00
J0J0 Todos
654c14490e
lastgenre: Refactor test_pretend to pytest 2025-10-15 09:54:41 +01:00
J0J0 Todos
d617e67199
lastgenre: Fix test_pretend_option
only one arg is passed to the info log anymore.
2025-10-15 09:52:32 +01:00
J0J0 Todos
1acec39525
lastgenre: Use apply methods during import 2025-10-15 09:52:32 +01:00
J0J0 Todos
8613b3573c
lastgenre: Refactor final genre apply
- Move item and genre apply to separate helper functions. Have one
  function for each to not overcomplicate implementation!
- Use a decorator log_and_pretend that logs and does the right thing
  depending on wheter --pretend was passed or not.
- Sets --force (internally) automatically if --pretend is given (this is
  a behavirol change needing discussion)
2025-10-15 09:52:32 +01:00
henry
0bf248d355
Add custom feat words for ftintitle (#6090)
## Description

For non English tracks (Swedish in my case) feat. words might be
something that ftintitle doesn't pick up.
Eg. for the song `Promoe med Afasi - Inflation`
[https://musicbrainz.org/recording/8e236347-61d6-4e11-9980-52f4cc6b905f](https://musicbrainz.org/recording/8e236347-61d6-4e11-9980-52f4cc6b905f)
the word `med` is `feat.` in Swedish.
With this PR you can add what ever word you wish to match as feat. so it
should cover any kind of language.

The config.yaml could look like this:
ftintitle:
  custom_feat_words: ["med"]

## To Do

<!--
- If you believe one of below checkpoints is not required for the change
you
are submitting, cross it out and check the box nonetheless to let us
know.
  For example: - [x] ~Changelog~
- Regarding the changelog, often it makes sense to add your entry only
once
reviewing is finished. That way you might prevent conflicts from other
PR's in
that file, as well as keep the chance high your description fits with
the
  latest revision of your feature/fix.
- Regarding documentation, bugfixes often don't require additions to the
docs.
- Please remove the descriptive sentences in braces from the enumeration
below,
  which helps to unclutter your PR description.
-->

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
- [x] Tests. (Very much encouraged but not strictly required.)
2025-10-14 20:38:02 -07:00
snejus
c1877b7cf5 Increment version to 2.5.1 2025-10-14 22:51:15 +00:00
Šarūnas Nejus
61cbc39c4a
Revert "Add git commit suffix to __version__ for development installs (#5967)" 2025-10-14 23:39:27 +01:00
Šarūnas Nejus
efe1a67e84
Revert "Fix dynamic versioning plugin not correctly installed in workflow (#6094)"
This reverts commit dc9b498ee8, reversing
changes made to 77842b72d7.
2025-10-14 23:38:01 +01:00
Sebastian Mohr
af022683fe
Legacy plugin copy not copying properties. (#6101)
The recently introduces `data_source_mismatch_penalty` property in the MetadataPlugin
class was not copied in the backwards compatibility layer. This PR
introduces a fixes this such that `cached_properties` are copied to
legacy metadata plugins.

This also includes a test for the expected behavior.

See also [beetcamp
issue](https://github.com/snejus/beetcamp/issues/85#issuecomment-3399273892).
2025-10-14 20:41:31 +02:00
Sebastian Mohr
391ca4ca26 Yet some more simplification. 2025-10-14 20:25:07 +02:00
Sebastian Mohr
365ff6b030 Added test additions 2025-10-14 19:55:50 +02:00
Sebastian Mohr
f339d8a4d3 slight simplification. 2025-10-14 19:55:50 +02:00
Sebastian Mohr
670c300625 Fixed issue with legacy plugin copy not copying properties. Also
added test for it
2025-10-14 19:55:50 +02:00
Šarūnas Nejus
ecea47320c
Load the last plugin class found in the namespace (#6100)
- Modified `_get_plugin` function to use `reversed()` when iterating
through `namespace.__dict__.values()`
- This ensures that we load _the last_ plugin class found in the
namespace.

Fixes #6093
2025-10-14 17:05:29 +01:00
Šarūnas Nejus
f33c030ebb
Convert replacements and Include URLs for :class: refs in release notes 2025-10-14 16:54:52 +01:00
Šarūnas Nejus
fbc12a358c
Add changelog note 2025-10-14 16:54:52 +01:00
Šarūnas Nejus
13f40de5bb
Make _verify_config method private to remove it from the docs 2025-10-14 16:21:33 +01:00
Šarūnas Nejus
7fa9a30b89
Add note regarding the last plugin class 2025-10-14 16:17:29 +01:00
Šarūnas Nejus
75a945d3d3
Initialise the last plugin class found in the plugin namespace 2025-10-14 15:14:55 +01:00
Jacob Danell
83858cd7ca Fixed too long text line 2025-10-14 14:08:30 +02:00
Jacob Danell
320ebf6a20 Fix misspelling 2025-10-14 14:07:45 +02:00
Šarūnas Nejus
dc9b498ee8
Fix dynamic versioning plugin not correctly installed in workflow (#6094)
It seems like the ci wokflows did not install the
poetry-dynamic-versioning correctly. We need a pipx inject for it to
work as expected.

closes #6089
2025-10-14 13:04:18 +01:00
Sebastian Mohr
31488e79da Removed additional linebreaks. 2025-10-14 12:58:54 +01:00
Sebastian Mohr
febb1d2e08 Removed test release file. 2025-10-14 12:58:54 +01:00
Sebastian Mohr
7f15a46081 Added perms to flow. 2025-10-14 12:58:54 +01:00
Sebastian Mohr
ac31bee4ca Reverted placeholder. 2025-10-14 12:58:54 +01:00
Sebastian Mohr
4ea37b4579 Added changelog entry fixed action to use sha. 2025-10-14 12:58:54 +01:00
Sebastian Mohr
d01f960e4f Fixed an issue where the poetry-dynamic-versioning-plugin was not used in
release artifacts.

Also adds a test_release workflow which allows to create the release
distribution.
2025-10-14 12:58:54 +01:00
Sebastian Mohr
77842b72d7
Adds a zero_disc_if_single_disc to the zero plugin (#6015)
Adds a `omit_single_disc` boolean configuration option to the zero
plugin for writing to files. Adds the logic that, if disctotal is set and 
there is only one disc in disctotal, that the disc is not set.

This keeps tags cleaner, only using disc on multi-disc albums. The
disctotal is not touched, particularly as this is not usually displayed
in most clients.

The field is removed only for writing the tags, but the disc number is
maintained in the database to avoid breaking anything that may depend on
a disc number or avoid possible loops or failed logic.

A column of disc 1 makes me feel there should be a disc 2, when most
albums are a single disc only.
2025-10-14 04:23:35 +02:00
Michael Krieger
df8cd23ae7 Add back tests as they were.
Add back tests as they were.
2025-10-14 03:17:34 +01:00
Michael Krieger
dc13308784 Remove tests. Update docs. Remove unnecessary return
Remove tests.  Update docs.  Remove unnecessary return.
2025-10-14 03:17:34 +01:00
Michael Krieger
b1c87cd98c Change parameter name, add return, add tests
Change the parameter name to omit_single_disc (vs previously zero_disc_if_single_disc)

Add return of 'fields_set' so that, if triggered by the command line `beets zero`, it will still effect the item.write.

Added tests.
2025-10-14 03:17:34 +01:00
Michael Krieger
5fc15bcfa4 Misc formatting changes 2025-10-14 03:17:34 +01:00
Michael Krieger
33b350a612 Adds a zero_disc_if_single_disc to the zero plugin
Adds a zero_disc_number_if_single_disc boolean to the zero plugin for writing to files. Adds the logic that, if disctotal is set and there is only one disc in disctotal, that the disc is not set.

This keeps tags cleaner, only using disc on multi-disc albums. The disctotal is not touched, particularly as this is not usually displayed in most clients.

The field is removed only for writing the tags, but the disc number is maintained in the database to avoid breaking anything that may depend on a disc number or avoid possible loops or failed logic.
2025-10-14 03:17:34 +01:00
Ember Light
0f0e38b0bf Add link in changelog 2025-10-12 22:40:55 +02:00
Ember Light
717809c52c Better custom_words documentation 2025-10-12 22:40:44 +02:00
Ember Light
b95a17d8d3 remove feat from custom_feat_words 2025-10-12 22:40:27 +02:00
Ember Light
af09e58fb0 Add new line after New features: 2025-10-12 21:40:22 +02:00
Ember Light
51c971f089 Fix sourcery-ai comments 2025-10-12 21:38:13 +02:00
Ember Light
e90738a6e2 Added changelog 2025-10-12 21:09:17 +02:00
Ember Light
992938f0ae Add documentation 2025-10-12 20:58:38 +02:00
Ember Light
37a5f9cb15 Add custom feat words 2025-10-12 20:47:51 +02:00
Sebastian Mohr
41e314247d
Modernize getting started guide and remove old contact info (#5807)
## Description

- removes all mailing list references in doc. 
- removes outdated installation instructions (python3.8 + slackware)
- modernizes the getting started guide

closes #5462
2025-10-11 15:07:26 +02:00
Sebastian Mohr
dcec327942 Developer Resources card now links to doc page. 2025-10-11 14:32:35 +02:00
Sebastian Mohr
dd9917d3f3 Removed yaml hyperlink. Changed dropdown naming. Use full console param
instead of short form.
2025-10-11 14:27:44 +02:00
Sebastian Mohr
32fdad1411 Enhanced changelog entry. 2025-10-11 13:55:29 +02:00
Sebastian Mohr
3a6769d3b9 Set sphinx dependencies as optional 2025-10-11 13:52:35 +02:00
Sebastian Mohr
1270364796 Modernized getting started guide. 2025-10-11 13:52:35 +02:00
Sebastian Mohr
7e81f23de6 Readded (outdated) mac instructions. No idea why they were dropped. 2025-10-11 13:52:35 +02:00
Sebastian Mohr
7caa68a141 Re-added macport instructions. Removed mailing list ref. Added section
header for pip and pipx. Removed python 3.13 attention.
2025-10-11 13:52:35 +02:00
Sebastian Mohr
e30772f0c1 Run formatter. 2025-10-11 13:52:35 +02:00
Sebastian Mohr
1aaaeb49ed Added pipx refernces 2025-10-11 13:52:35 +02:00
Sebastian Mohr
81c622bcec Removed duplicate yet. 2025-10-11 13:52:35 +02:00
Sebastian Mohr
3b5eee59ee Added changelog entry. 2025-10-11 13:52:34 +02:00
Sebastian Mohr
103b501af7 Removed mailing list ref in index.rst 2025-10-11 13:51:20 +02:00
Sebastian Mohr
116357e2f6 Removed outdated installation instructions.
- macport: stuck on 1.6
- slackware: stuck on 1.6
- OpenBSD: stuck on 1.6

Remove twitter reference. Removed mailing list reference.
2025-10-11 13:51:20 +02:00
Martin Atukunda
545213421b
feat(plugin/web): support for nexttrack keypress 2025-10-09 20:11:19 +03:00
Emi Katagiri-Simpson
4a43191c31
BUG: Wrong path edited when running config -e
Previously: ALWAYS edited the default config path
Corrected: When the --config <path> option is used, that path is edited
2025-03-23 15:58:06 -04:00
Emi Katagiri-Simpson
7acf2b3acf
Dereference symlinks before hardlinking
(see #5676)
2025-03-22 23:15:45 -04:00
152 changed files with 11956 additions and 6808 deletions

View file

@ -73,3 +73,11 @@ d93ddf8dd43e4f9ed072a03829e287c78d2570a2
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

2
.github/CODEOWNERS vendored
View file

@ -3,3 +3,5 @@
# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beetsplug/titlecase.py @henry-oberholtzer
/beetsplug/mbpseudo.py @asardaes

View file

@ -10,7 +10,7 @@ jobs:
check_changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get all updated Python files
id: changed-python-files

View file

@ -20,17 +20,17 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.platform }}
env:
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
- 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@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: poetry
@ -39,7 +39,15 @@ jobs:
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt update
sudo apt install --yes --no-install-recommends ffmpeg gobject-introspection gstreamer1.0-plugins-base python3-gst-1.0 libcairo2-dev libgirepository-2.0-dev pandoc imagemagick
sudo apt install --yes --no-install-recommends \
ffmpeg \
gobject-introspection \
gstreamer1.0-plugins-base \
python3-gst-1.0 \
libcairo2-dev \
libgirepository-2.0-dev \
pandoc \
imagemagick
- name: Get changed lyrics files
id: lyrics-update
@ -90,10 +98,10 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Get the coverage report
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: coverage-report

View file

@ -3,16 +3,20 @@ 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@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: 3.9
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
- name: Install dependencies

View file

@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
jobs:
changed-files:
@ -24,7 +24,7 @@ jobs:
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@v4
- uses: actions/checkout@v5
- name: Get changed docs files
id: changed-doc-files
uses: tj-actions/changed-files@v46
@ -56,10 +56,10 @@ jobs:
name: Check formatting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -77,10 +77,10 @@ jobs:
name: Check linting
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -97,10 +97,10 @@ jobs:
name: Check types with mypy
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -120,10 +120,10 @@ jobs:
name: Check docs
needs: changed-files
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry

View file

@ -8,7 +8,7 @@ on:
required: true
env:
PYTHON_VERSION: 3.9
PYTHON_VERSION: "3.10"
NEW_VERSION: ${{ inputs.version }}
NEW_TAG: v${{ inputs.version }}
@ -17,10 +17,10 @@ jobs:
name: Bump version, commit and create tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Python tools
uses: BrandonLWhite/pipx-install-action@v1.0.3
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -45,13 +45,13 @@ jobs:
outputs:
changelog: ${{ steps.generate_changelog.outputs.changelog }}
steps:
- uses: actions/checkout@v4
- 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@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: poetry
@ -92,7 +92,7 @@ jobs:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
@ -107,7 +107,7 @@ jobs:
CHANGELOG: ${{ needs.build.outputs.changelog }}
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/

4
.gitignore vendored
View file

@ -95,5 +95,5 @@ ENV/
# pyright
pyrightconfig.json
# Versioning
beets/_version.py
# Pyrefly
pyrefly.toml

View file

@ -124,12 +124,12 @@ command. Instead, you can activate the virtual environment in your shell with:
$ poetry shell
You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
commands directly, for example:
::
$ (beets-py3.9) pytest
$ (beets-py3.10) pytest
Additionally, poethepoet_ task runner assists us with the most common
operations. Formatting, linting, testing are defined as ``poe`` tasks in
@ -286,31 +286,6 @@ 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.
Handling Paths
~~~~~~~~~~~~~~
A great deal of convention deals with the handling of **paths**. Paths are
stored internally—in the database, for instance—as byte strings (i.e., ``bytes``
instead of ``str`` in Python 3). This is because POSIX operating systems path
names are only reliably usable as byte strings—operating systems typically
recommend but do not require that filenames use a given encoding, so violations
of any reported encoding are inevitable. On Windows, the strings are always
encoded with UTF-8; on Unix, the encoding is controlled by the filesystem. Here
are some guidelines to follow:
- If you have a Unicode path or youre not sure whether something is Unicode or
not, pass it through ``bytestring_path`` function in the ``beets.util`` module
to convert it to bytes.
- Pass every path name through the ``syspath`` function (also in ``beets.util``)
before sending it to any *operating system* file operation (``open``, for
example). This is necessary to use long filenames (which, maddeningly, must be
Unicode) on Windows. This allows us to consistently store bytes in the
database but use the native encoding rule on both POSIX and Windows.
- Similarly, the ``displayable_path`` utility function converts bytestring paths
to a Unicode string for displaying to the user. Every time you want to print
out a string to the terminal or log it with the ``logging`` module, feed it
through this function.
Editor Settings
~~~~~~~~~~~~~~~

View file

@ -85,7 +85,7 @@ simple if you know a little Python.
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
Install
-------

View file

@ -79,7 +79,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
설치
-------

View file

@ -17,23 +17,18 @@ from sys import stderr
import confuse
# Version management using poetry-dynamic-versioning
from ._version import __version__, __version_tuple__
from .util import deprecate_imports
from .util.deprecation import deprecate_imports
__version__ = "2.5.1"
__author__ = "Adrian Sampson <adrian@radbox.org>"
def __getattr__(name: str):
"""Handle deprecated imports."""
return deprecate_imports(
old_module=__name__,
new_module_by_name={
"art": "beetsplug._utils",
"vfs": "beetsplug._utils",
},
name=name,
version="3.0.0",
__name__,
{"art": "beetsplug._utils", "vfs": "beetsplug._utils"},
name,
)
@ -55,6 +50,3 @@ class IncludeLazyConfig(confuse.LazyConfig):
config = IncludeLazyConfig("beets", __name__)
__all__ = ["__version__", "__version_tuple__", "config"]

View file

@ -1,7 +0,0 @@
# This file is auto-generated during the build process.
# Do not edit this file directly.
# Placeholders are replaced during substitution.
# Run `git update-index --assume-unchanged beets/_version.py`
# to ignore local changes to this file.
__version__ = "0.0.0"
__version_tuple__ = (0, 0, 0)

View file

@ -16,16 +16,15 @@
from __future__ import annotations
import warnings
from importlib import import_module
from typing import TYPE_CHECKING, Union
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 ..util import deprecate_imports
from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
from .match import Proposal, Recommendation, tag_album, tag_item
@ -37,18 +36,13 @@ if TYPE_CHECKING:
def __getattr__(name: str):
if name == "current_metadata":
warnings.warn(
(
f"'beets.autotag.{name}' is deprecated and will be removed in"
" 3.0.0. Use 'beets.util.get_most_common_tags' instead."
),
DeprecationWarning,
stacklevel=2,
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, "3.0.0"
__name__, {"Distance": "beets.autotag.distance"}, name
)
@ -117,8 +111,8 @@ SPECIAL_FIELDS = {
def _apply_metadata(
info: Union[AlbumInfo, TrackInfo],
db_obj: Union[Album, Item],
info: AlbumInfo | TrackInfo,
db_obj: Album | Item,
nullable_fields: Sequence[str] = [],
):
"""Set the db_obj's metadata to match the info."""

View file

@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
import lap
import numpy as np
from beets import config, logging, metadata_plugins
from beets import config, logging, metadata_plugins, plugins
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
from beets.util import get_most_common_tags
@ -274,12 +274,17 @@ def tag_album(
log.debug("Searching for album ID: {}", search_id)
if info := metadata_plugins.album_for_id(search_id):
_add_candidate(items, candidates, info)
if opt_candidate := candidates.get(info.album_id):
plugins.send("album_matched", match=opt_candidate)
# Use existing metadata or text search.
else:
# Try search based on current ID.
if info := match_by_id(items):
_add_candidate(items, candidates, info)
for candidate in candidates.values():
plugins.send("album_matched", match=candidate)
rec = _recommendation(list(candidates.values()))
log.debug("Album ID match recommendation is {}", rec)
if candidates and not config["import"]["timid"]:
@ -313,6 +318,8 @@ def tag_album(
items, search_artist, search_album, va_likely
):
_add_candidate(items, candidates, matched_candidate)
if opt_candidate := candidates.get(matched_candidate.album_id):
plugins.send("album_matched", match=opt_candidate)
log.debug("Evaluating {} candidates.", len(candidates))
# Sort and get the recommendation.

View file

@ -26,9 +26,16 @@ import threading
import time
from abc import ABC
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
)
from sqlite3 import Connection, sqlite_version_info
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic
from typing import TYPE_CHECKING, Any, AnyStr, Generic
from typing_extensions import TypeVar # default value support
from unidecode import unidecode
@ -940,10 +947,10 @@ class Transaction:
def __exit__(
self,
exc_type: type[Exception],
exc_value: Exception,
traceback: TracebackType,
):
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.
@ -965,6 +972,8 @@ class Transaction:
):
raise DBCustomFunctionError()
return None
def query(
self, statement: str, subvals: Sequence[SQLiteType] = ()
) -> list[sqlite3.Row]:

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING, Sequence
from typing import TYPE_CHECKING
from beets import config, dbcore, library, logging, plugins, util
from beets.importer.tasks import Action
@ -25,6 +25,8 @@ from . import stages as stagefuncs
from .state import ImportState
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.util import PathBytes
from .tasks import ImportTask

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import itertools
import logging
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from beets import config, plugins
from beets.util import MoveOperation, displayable_path, pipeline
@ -30,6 +30,8 @@ from .tasks import (
)
if TYPE_CHECKING:
from collections.abc import Callable
from beets import library
from .session import ImportSession

View file

@ -20,9 +20,10 @@ import re
import shutil
import time
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
from enum import Enum
from tempfile import mkdtemp
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import mediafile

View file

@ -1,4 +1,4 @@
from beets.util import deprecate_imports
from beets.util.deprecation import deprecate_imports
from .exceptions import FileOperationError, ReadError, WriteError
from .library import Library
@ -13,7 +13,7 @@ NEW_MODULE_BY_NAME = dict.fromkeys(
def __getattr__(name: str):
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name, "3.0.0")
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name)
__all__ = [

View file

@ -22,6 +22,7 @@ calls (`debug`, `info`, etc).
from __future__ import annotations
import re
import threading
from copy import copy
from logging import (
@ -37,7 +38,7 @@ from logging import (
RootLogger,
StreamHandler,
)
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
__all__ = [
"DEBUG",
@ -54,6 +55,8 @@ __all__ = [
]
if TYPE_CHECKING:
from collections.abc import Mapping
T = TypeVar("T")
from types import TracebackType
@ -66,6 +69,15 @@ if TYPE_CHECKING:
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
# 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.
@ -80,6 +92,10 @@ def _logsafe(val: T) -> str | T:
# 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.

View file

@ -13,17 +13,11 @@
# included in all copies or substantial portions of the Software.
import warnings
import mediafile
warnings.warn(
"beets.mediafile is deprecated; use mediafile instead",
# Show the location of the `import mediafile` statement as the warning's
# source, rather than this file, such that the offending module can be
# identified easily.
stacklevel=2,
)
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():
@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items():
globals()[key] = value
# Cleanup namespace.
del key, value, warnings, mediafile
del key, value, mediafile

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import abc
import re
from functools import cache, cached_property
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
import unidecode
from confuse import NotFoundError
@ -22,7 +22,7 @@ from beets.util.id_extractors import extract_release_id
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo

View file

@ -20,12 +20,10 @@ import abc
import inspect
import re
import sys
import warnings
from collections import defaultdict
from functools import wraps
from functools import cached_property, wraps
from importlib import import_module
from pathlib import Path
from types import GenericAlias
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
import mediafile
@ -34,6 +32,7 @@ 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, Sequence
@ -72,6 +71,7 @@ EventType = Literal[
"album_imported",
"album_removed",
"albuminfo_received",
"album_matched",
"before_choose_candidate",
"before_item_moved",
"cli_exit",
@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
list
)
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
template_funcs: TFuncMap[str] | None = None
template_fields: TFuncMap[Item] | None = None
album_template_fields: TFuncMap[Album] | None = None
template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type]
template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type]
album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type]
name: str
config: ConfigView
@ -184,20 +184,32 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
):
return
warnings.warn(
f"{cls.__name__} is used as a legacy metadata source. "
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
"Support for this will be removed in the v3.0.0 release!",
DeprecationWarning,
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: (
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__ not in MetadataSourcePlugin.__abstractmethods__
and not hasattr(cls, f.__name__)
and f.__name__
and not getattr(f, "__isabstractmethod__", False)
and not hasattr(BeetsPlugin, f.__name__)
)
),
):
setattr(cls, name, method)
@ -208,8 +220,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
self.name = name or self.__module__.split(".")[-1]
self.config = beets.config[self.name]
# Set class attributes if they are not already set
# for the type of plugin.
# If the class attributes are not set, initialize as instance attributes.
# TODO: Revise with v3.0.0, see also type: ignore[valid-type] above
if not self.template_funcs:
self.template_funcs = {}
if not self.template_fields:
@ -228,9 +240,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
# 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)
self.register_listener("pluginload", self._verify_config)
def verify_config(self, *_, **__) -> None:
def _verify_config(self, *_, **__) -> None:
"""Verify plugin configuration.
If deprecated 'source_weight' option is explicitly set by the user, they
@ -245,16 +257,19 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
):
return
message = (
"'source_weight' configuration option is deprecated and will be"
" removed in v3.0.0. Use 'data_source_mismatch_penalty' instead"
)
for source in self.config.root().sources:
if "source_weight" in (source.get(self.name) or {}):
if source.filename: # user config
self._log.warning(message)
deprecate_for_user(
self._log,
f"'{self.name}.source_weight' configuration option",
f"'{self.name}.data_source_mismatch_penalty'",
)
else: # 3rd-party plugin config
warnings.warn(message, DeprecationWarning, stacklevel=0)
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
@ -357,8 +372,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
"""
def helper(func: TFunc[str]) -> TFunc[str]:
if cls.template_funcs is None:
cls.template_funcs = {}
cls.template_funcs[name] = func
return func
@ -373,8 +386,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
"""
def helper(func: TFunc[Item]) -> TFunc[Item]:
if cls.template_fields is None:
cls.template_fields = {}
cls.template_fields[name] = func
return func
@ -403,16 +414,22 @@ def get_plugin_names() -> list[str]:
# *contain* a `beetsplug` package.
sys.path += paths
plugins = unique_list(beets.config["plugins"].as_str_seq())
# TODO: Remove in v3.0.0
if (
"musicbrainz" not in plugins
and "musicbrainz" in beets.config
and beets.config["musicbrainz"].get().get("enabled")
):
plugins.append("musicbrainz")
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]
@ -422,6 +439,12 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
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:
@ -429,12 +452,9 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
except Exception as exc:
raise PluginImportError(name) from exc
for obj in namespace.__dict__.values():
for obj in reversed(namespace.__dict__.values()):
if (
inspect.isclass(obj)
and not isinstance(
obj, GenericAlias
) # seems to be needed for python <= 3.9 only
and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin
and not inspect.isabstract(obj)
@ -551,7 +571,6 @@ def template_funcs() -> TFuncMap[str]:
"""
funcs: TFuncMap[str] = {}
for plugin in find_plugins():
if plugin.template_funcs:
funcs.update(plugin.template_funcs)
return funcs
@ -578,14 +597,13 @@ F = TypeVar("F")
def _check_conflicts_and_merge(
plugin: BeetsPlugin, plugin_funcs: dict[str, F] | None, funcs: dict[str, F]
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 plugin_funcs:
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
raise PluginConflictError(
@ -632,13 +650,17 @@ def send(event: EventType, **arguments: Any) -> list[Any]:
]
def feat_tokens(for_artist: bool = True) -> str:
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 (

View file

@ -107,7 +107,11 @@ def item(lib=None, **kwargs):
# Dummy import session.
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
cls = commands.TerminalImportSession if cli else importer.ImportSession
cls = (
commands.import_.session.TerminalImportSession
if cli
else importer.ImportSession
)
return cls(lib, loghandler, paths, query)

View file

@ -54,7 +54,7 @@ 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 TerminalImportSession
from beets.ui.commands.import_.session import TerminalImportSession
from beets.util import (
MoveOperation,
bytestring_path,

View file

@ -23,16 +23,15 @@ import errno
import optparse
import os.path
import re
import shutil
import sqlite3
import struct
import sys
import textwrap
import traceback
import warnings
from difflib import SequenceMatcher
from functools import cache
from itertools import chain
from typing import Any, Callable, Literal
from typing import TYPE_CHECKING, Any, Literal
import confuse
@ -40,8 +39,12 @@ 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.deprecation import deprecate_for_maintainers
from beets.util.functemplate import template
if TYPE_CHECKING:
from collections.abc import Callable
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == "win32":
try:
@ -111,11 +114,7 @@ def decargs(arglist):
.. deprecated:: 2.4.0
This function will be removed in 3.0.0.
"""
warnings.warn(
"decargs() is deprecated and will be removed in version 3.0.0.",
DeprecationWarning,
stacklevel=2,
)
deprecate_for_maintainers("'beets.ui.decargs'")
return arglist
@ -699,27 +698,11 @@ def get_replacements():
return replacements
def term_width():
@cache
def term_width() -> int:
"""Get the width (columns) of the terminal."""
fallback = config["ui"]["terminal_width"].get(int)
# The fcntl and termios modules are not available on non-Unix
# platforms, so we fall back to a constant.
try:
import fcntl
import termios
except ImportError:
return fallback
try:
buf = fcntl.ioctl(0, termios.TIOCGWINSZ, " " * 4)
except OSError:
return fallback
try:
height, width = struct.unpack("hh", buf)
except struct.error:
return fallback
return width
columns, _ = shutil.get_terminal_size(fallback=(0, 0))
return columns if columns else config["ui"]["terminal_width"].get(int)
def split_into_lines(string, width_tuple):
@ -1078,7 +1061,9 @@ def _field_diff(field, old, old_fmt, new, new_fmt):
return f"{oldstr} -> {newstr}"
def show_model_changes(new, old=None, fields=None, always=False):
def show_model_changes(
new, old=None, fields=None, always=False, print_obj: bool = True
):
"""Given a Model object, print a list of changes from its pristine
version stored in the database. Return a boolean indicating whether
any changes were found.
@ -1117,7 +1102,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
)
# Print changes.
if changes or always:
if print_obj and (changes or always):
print_(format(old))
if changes:
print_("\n".join(changes))
@ -1125,76 +1110,9 @@ def show_model_changes(new, old=None, fields=None, always=False):
return bool(changes)
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(util.displayable_path, sources))
destinations = list(map(util.displayable_path, destinations))
# Calculate widths for terminal split
col_width = (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)
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(" -> ")
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)
print_(f"{color_source} {' ' * pad} -> {color_dest}")
# Helper functions for option parsing.
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 UserError(
f"supplied argument `{value}' is not of the form `key=value'"
)
option_values[key] = value
class CommonOptionsParser(optparse.OptionParser):
"""Offers a simple way to add common formatting options.
@ -1680,9 +1598,9 @@ def _raw_main(args: list[str], lib=None) -> None:
and subargs[0] == "config"
and ("-e" in subargs or "--edit" in subargs)
):
from beets.ui.commands import config_edit
from beets.ui.commands.config import config_edit
return config_edit()
return config_edit(options)
test_lib = bool(lib)
subcommands, lib = _setup(options, lib)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
# 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"]

View file

@ -0,0 +1,117 @@
"""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"

View file

@ -0,0 +1,93 @@
"""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()
try:
if not os.path.isfile(path):
open(path, "w+").close()
interactive_open([path], editor)
except OSError as exc:
message = f"Could not edit configuration: {exc}"
if not editor:
message += (
". Please set the VISUAL (or EDITOR) environment variable"
)
raise ui.UserError(message)
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

View file

@ -0,0 +1,41 @@
"""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

22
beets/ui/commands/help.py Normal file
View file

@ -0,0 +1,22 @@
"""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()

View file

@ -0,0 +1,341 @@
"""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

View file

@ -0,0 +1,570 @@
import os
from collections.abc import Sequence
from functools import cached_property
from beets import autotag, config, ui
from beets.autotag import hooks
from beets.util import displayable_path
from beets.util.units import human_seconds_short
VARIOUS_ARTISTS = "Various Artists"
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.
"""
@cached_property
def changed_prefix(self) -> str:
return ui.colorize("changed", "\u2260")
cur_artist = None
# cur_album set if album, cur_title set if singleton
cur_album = None
cur_title = None
match = None
indent_header = ""
indent_detail = ""
def __init__(self):
# Read match header indentation width from config.
match_header_indent_width = config["ui"]["import"]["indentation"][
"match_header"
].as_number()
self.indent_header = ui.indent(match_header_indent_width)
# Read match detail indentation width from config.
match_detail_indent_width = config["ui"]["import"]["indentation"][
"match_details"
].as_number()
self.indent_detail = ui.indent(match_detail_indent_width)
# Read match tracklist indentation width from config
match_tracklist_indent_width = config["ui"]["import"]["indentation"][
"match_tracklist"
].as_number()
self.indent_tracklist = ui.indent(match_tracklist_indent_width)
self.layout = config["ui"]["import"]["layout"].as_choice(
{
"column": 0,
"newline": 1,
}
)
def print_layout(
self, indent, left, right, separator=" -> ", max_width=None
):
if not max_width:
# If no max_width provided, use terminal width
max_width = ui.term_width()
if self.layout == 0:
ui.print_column_layout(indent, left, right, separator, max_width)
else:
ui.print_newline_layout(indent, left, right, separator, max_width)
def show_match_header(self):
"""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)}):"
)
if isinstance(self.match.info, autotag.hooks.AlbumInfo):
# Matching an album - print that
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.album}"
)
else:
# Matching a single track
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.title}"
)
ui.print_(
self.indent_header
+ dist_colorize(artist_album_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 = ui.colorize("text_faint", f"{self.match.info.data_url}")
ui.print_(f"{self.indent_header}{url}")
def show_match_details(self):
"""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
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = "", ""
if artist_l != artist_r:
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
left = {
"prefix": f"{self.changed_prefix} Artist: ",
"contents": artist_l,
"suffix": "",
}
right = {"prefix": "", "contents": artist_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Artist:", artist_r)
if self.cur_album:
# Album
album_l, album_r = self.cur_album or "", self.match.info.album
if (
self.cur_album != self.match.info.album
and self.match.info.album != VARIOUS_ARTISTS
):
album_l, album_r = ui.colordiff(album_l, album_r)
left = {
"prefix": f"{self.changed_prefix} Album: ",
"contents": album_l,
"suffix": "",
}
right = {"prefix": "", "contents": album_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Album:", album_r)
elif self.cur_title:
# Title - for singletons
title_l, title_r = self.cur_title or "", self.match.info.title
if self.cur_title != self.match.info.title:
title_l, title_r = ui.colordiff(title_l, title_r)
left = {
"prefix": f"{self.changed_prefix} Title: ",
"contents": title_l,
"suffix": "",
}
right = {"prefix": "", "contents": title_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Title:", title_r)
def make_medium_info_line(self, track_info):
"""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):
"""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, track_info):
"""Format colored track indices."""
cur_track = self.format_index(item)
new_track = self.format_index(track_info)
changed = False
# Choose color based on change.
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 = ui.colorize(highlight_color, f"(#{cur_track})")
rhs_track = ui.colorize(highlight_color, f"(#{new_track})")
return lhs_track, rhs_track, changed
@staticmethod
def make_track_titles(item, track_info):
"""Format colored track titles."""
new_title = track_info.title
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 = ui.colordiff(cur_title, new_title)
return cur_col, new_col, cur_title != new_title
@staticmethod
def make_track_lengths(item, track_info):
"""Format colored track lengths."""
changed = False
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 = ui.colorize(highlight_color, cur_length)
rhs_length = ui.colorize(highlight_color, new_length)
return lhs_length, rhs_length, changed
def make_line(self, item, track_info):
"""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 = {
"prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ",
"contents": lhs_title,
"suffix": f" {lhs_length}",
}
rhs = {"prefix": "", "contents": "", "suffix": ""}
if not changed:
# Only return the left side, as nothing changed.
return (lhs, rhs)
else:
# Construct a dictionary for the "changed to" side
rhs = {
"prefix": f"{rhs_track} ",
"contents": rhs_title,
"suffix": f" {rhs_length}",
}
return (lhs, rhs)
def print_tracklist(self, lines):
"""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
def get_width(side):
"""Return the width of left or right in uncolorized characters."""
try:
return len(
ui.uncolorize(
" ".join(
[side["prefix"], side["contents"], side["suffix"]]
)
)
)
except KeyError:
# An empty dictionary -> Nothing to report
return 0
# Check how to fit content into terminal window
indent_width = len(self.indent_tracklist)
terminal_width = ui.term_width()
joiner_width = len("".join(["* ", " -> "]))
col_width = (terminal_width - indent_width - joiner_width) // 2
max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines)
max_width_r = max(get_width(line_tuple[1]) for line_tuple 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["width"] = col_width_l
right["width"] = col_width_r
self.print_layout(self.indent_tracklist, left, right)
class AlbumChange(ChangeRepresentation):
"""Album change representation, setting cur_album"""
def __init__(self, cur_artist, cur_album, match):
super().__init__()
self.cur_artist = cur_artist
self.cur_album = cur_album
self.match = match
def show_match_tracks(self):
"""Print out the tracks of the match, summarizing changes the match
suggests for them.
"""
# Tracks.
# match is an AlbumMatch NamedTuple, mapping is a dict
# Sort the pairs by the track_info index (at index 1 of the NamedTuple)
pairs = list(self.match.mapping.items())
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
# Build up LHS and RHS for track difference display. The `lines` list
# contains `(left, right)` tuples.
lines = []
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_(ui.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_(ui.colorize("text_warning", line))
class TrackChange(ChangeRepresentation):
"""Track change representation, comparing item with match."""
def __init__(self, cur_artist, cur_title, match):
super().__init__()
self.cur_artist = cur_artist
self.cur_title = cur_title
self.match = match
def show_change(cur_artist, cur_album, match):
"""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_artist, cur_album=cur_album, match=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, match):
"""Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object.
"""
change = TrackChange(
cur_artist=item.artist, cur_title=item.title, match=match
)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
def disambig_string(info):
"""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_colorize(string, dist):
"""Formats a string as a colorized similarity string according to
a distance.
"""
if dist <= config["match"]["strong_rec_thresh"].as_number():
string = ui.colorize("text_success", string)
elif dist <= config["match"]["medium_rec_thresh"].as_number():
string = ui.colorize("text_warning", string)
else:
string = ui.colorize("text_error", string)
return string
def dist_string(dist):
"""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, limit=None):
"""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 ui.colorize("changed", penalty_string)

View file

@ -0,0 +1,550 @@
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.units import human_bytes, human_seconds_short
from .display import (
disambig_string,
dist_colorize,
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 = ui.colorize("import_path", path_str0)
items_str0 = f"({len(task.items)} items)"
items_str = ui.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):
"""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.
"""
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} -"
f" {match.info.title if singleton else match.info.album}"
)
if i == 0:
metadata = dist_colorize(metadata, match.distance)
else:
metadata = ui.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()

25
beets/ui/commands/list.py Normal file
View file

@ -0,0 +1,25 @@
"""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

162
beets/ui/commands/modify.py Normal file
View file

@ -0,0 +1,162 @@
"""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

200
beets/ui/commands/move.py Normal file
View file

@ -0,0 +1,200 @@
"""The 'move' command: Move/copy files to the library or a new base directory."""
import os
from beets import logging, ui
from beets.util import (
MoveOperation,
PathLike,
displayable_path,
normpath,
syspath,
)
from .utils import do_query
# 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 = ui.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 = ui.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

View file

@ -0,0 +1,84 @@
"""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

View file

@ -0,0 +1,62 @@
"""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

196
beets/ui/commands/update.py Normal file
View file

@ -0,0 +1,196 @@
"""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 .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_(ui.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

View file

@ -0,0 +1,29 @@
"""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

View file

@ -0,0 +1,23 @@
"""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"]

View file

@ -0,0 +1,60 @@
"""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, albums = 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

View file

@ -27,9 +27,8 @@ import subprocess
import sys
import tempfile
import traceback
import warnings
from collections import Counter
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from contextlib import suppress
from enum import Enum
from functools import cache
@ -41,12 +40,12 @@ from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
ClassVar,
Generic,
NamedTuple,
TypeVar,
Union,
cast,
)
from unidecode import unidecode
@ -168,6 +167,12 @@ class MoveOperation(Enum):
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.
@ -577,10 +582,14 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
# 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:
os.link(syspath(path), syspath(dest))
dest_path.hardlink_to(origin_path)
except NotImplementedError:
raise FilesystemError(
"OS does not support hard links.link",
@ -1052,7 +1061,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
pool.join()
class cached_classproperty:
class cached_classproperty(Generic[T]):
"""Descriptor implementing cached class properties.
Provides class-level dynamic property behavior where the getter function is
@ -1060,9 +1069,9 @@ class cached_classproperty:
instance properties, this operates on the class rather than instances.
"""
cache: ClassVar[dict[tuple[Any, str], Any]] = {}
cache: ClassVar[dict[tuple[type[object], str], object]] = {}
name: str
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
@ -1078,21 +1087,21 @@ class cached_classproperty:
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
#
# Therefore, we just use `Any` here, which is not ideal, but works.
def __init__(self, getter: Callable[[Any], Any]) -> None:
def __init__(self, getter: Callable[..., T]) -> None:
"""Initialize the descriptor with the property getter function."""
self.getter = getter
self.getter: Callable[..., T] = getter
def __set_name__(self, owner: Any, name: str) -> None:
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: Any, owner: type[Any]) -> Any:
def __get__(self, instance: object, owner: type[object]) -> T:
"""Compute and cache if needed, and return the property value."""
key = owner, self.name
key: tuple[type[object], str] = owner, self.name
if key not in self.cache:
self.cache[key] = self.getter(owner)
return self.cache[key]
return cast(T, self.cache[key])
class LazySharedInstance(Generic[T]):
@ -1191,26 +1200,3 @@ def get_temp_filename(
def unique_list(elements: Iterable[T]) -> list[T]:
"""Return a list with unique elements in the original order."""
return list(dict.fromkeys(elements))
def deprecate_imports(
old_module: str, new_module_by_name: dict[str, str], name: str, version: 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):
warnings.warn(
(
f"'{old_module}.{name}' is deprecated and will be removed"
f" in {version}. Use '{new_module}.{name}' instead."
),
DeprecationWarning,
stacklevel=2,
)
return getattr(import_module(new_module), name)
raise AttributeError(f"module '{old_module}' has no attribute '{name}'")

View file

@ -26,7 +26,7 @@ import subprocess
from abc import ABC, abstractmethod
from enum import Enum
from itertools import chain
from typing import Any, ClassVar, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlencode
from beets import logging, util
@ -37,6 +37,9 @@ from beets.util import (
syspath,
)
if TYPE_CHECKING:
from collections.abc import Mapping
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger("beets")

60
beets/util/deprecation.py Normal file
View file

@ -0,0 +1,60 @@
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}'")

View file

@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False):
decorator_list=[],
)
# The ast.Module signature changed in 3.8 to accept a list of types to
# ignore.
mod = ast.Module([func_def], [])
ast.fix_missing_locations(mod)

View file

@ -20,10 +20,9 @@ import os
import stat
import sys
from pathlib import Path
from typing import Union
def is_hidden(path: Union[bytes, Path]) -> bool:
def is_hidden(path: bytes | Path) -> bool:
"""
Determine whether the given path is treated as a 'hidden file' by the OS.
"""

View file

@ -36,10 +36,13 @@ from __future__ import annotations
import queue
import sys
from threading import Lock, Thread
from typing import Callable, Generator, TypeVar
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__"

View file

@ -19,14 +19,7 @@ from __future__ import annotations
import json
import re
from datetime import datetime, timedelta
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
Literal,
Sequence,
overload,
)
from typing import TYPE_CHECKING, Literal, overload
import confuse
from requests_oauthlib import OAuth1Session
@ -42,6 +35,8 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from beets.importer import ImportSession
from beets.library import Item

View file

@ -283,7 +283,7 @@ class BaseServer:
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("utf-8"))
self.ctrl_sock.sendall((f"{message}\n").encode())
def _send_event(self, event):
"""Notify subscribed connections of an event."""

View file

@ -27,7 +27,16 @@ 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 # noqa: E402
Gst.init(None)

View file

@ -18,8 +18,8 @@ autotagger. Requires the pyacoustid library.
import re
from collections import defaultdict
from collections.abc import Iterable
from functools import cached_property, partial
from typing import Iterable
import acoustid
import confuse

View file

@ -95,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
return False
def should_transcode(item, fmt):
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"]
config["convert"]["never_convert_lossy_files"].get(bool)
and item.format.lower() not in LOSSLESS_FORMATS
):
return False
@ -236,6 +242,16 @@ class ConvertPlugin(BeetsPlugin):
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]
@ -259,6 +275,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(empty_opts)
items = task.imported_items()
@ -272,6 +289,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
# Utilities converted from functions to methods on logging overhaul
@ -347,6 +365,7 @@ class ConvertPlugin(BeetsPlugin):
pretend=False,
link=False,
hardlink=False,
force=False,
):
"""A pipeline thread that converts `Item` objects from a
library.
@ -372,11 +391,11 @@ class ConvertPlugin(BeetsPlugin):
if keep_new:
original = dest
converted = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
converted = replace_ext(converted, ext)
else:
original = item.path
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
dest = replace_ext(dest, ext)
converted = dest
@ -406,7 +425,7 @@ class ConvertPlugin(BeetsPlugin):
)
util.move(item.path, original)
if should_transcode(item, fmt):
if should_transcode(item, fmt, force):
linked = False
try:
self.encode(command, original, converted, pretend)
@ -577,6 +596,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
) = self._get_opts_and_config(opts)
if opts.album:
@ -613,6 +633,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
threads,
items,
force,
)
if playlist:
@ -735,7 +756,7 @@ class ConvertPlugin(BeetsPlugin):
else:
hardlink = self.config["hardlink"].get(bool)
link = self.config["link"].get(bool)
force = getattr(opts, "force", False)
return (
dest,
threads,
@ -745,6 +766,7 @@ class ConvertPlugin(BeetsPlugin):
hardlink,
link,
playlist,
force,
)
def _parallel_convert(
@ -758,13 +780,21 @@ class ConvertPlugin(BeetsPlugin):
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
dest,
keep_new,
path_formats,
fmt,
pretend,
link,
hardlink,
force,
)
for _ in range(threads)
]

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import collections
import time
from typing import TYPE_CHECKING, Literal, Sequence
from typing import TYPE_CHECKING, Literal
import requests
@ -32,6 +32,8 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Item, Library
from ._typing import JSONDict

View file

@ -27,7 +27,7 @@ import time
import traceback
from functools import cache
from string import ascii_lowercase
from typing import TYPE_CHECKING, Sequence, cast
from typing import TYPE_CHECKING, cast
import confuse
from discogs_client import Client, Master, Release
@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.metadata_plugins import MetadataSourcePlugin
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Sequence
from beets.library import Item
@ -132,9 +132,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
"user_token": "",
"separator": ", ",
"index_tracks": False,
"featured_string": "Feat.",
"append_style_genre": False,
"strip_disambiguation": True,
"featured_string": "Feat.",
"anv": {
"artist_credit": True,
"artist": False,

View file

@ -25,7 +25,8 @@ import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.importer import Action
from beets.ui.commands import PromptChoice, _do_query
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.
@ -176,7 +177,7 @@ class EditPlugin(plugins.BeetsPlugin):
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)
items, albums = do_query(lib, args, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_("Nothing to edit.")

View file

@ -23,7 +23,7 @@ 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, Tuple, Type
from typing import TYPE_CHECKING, AnyStr, ClassVar, Literal
import confuse
import requests
@ -86,7 +86,7 @@ class Candidate:
path: None | bytes = None,
url: None | str = None,
match: None | MetadataMatch = None,
size: None | Tuple[int, int] = None,
size: None | tuple[int, int] = None,
):
self._log = log
self.path = path
@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource):
"""
if not (album.albumartist and album.album):
return
search_string = f"{album.albumartist},{album.album}".encode("utf-8")
search_string = f"{album.albumartist},{album.album}".encode()
try:
response = self.request(
@ -1293,7 +1293,7 @@ class CoverArtUrl(RemoteArtSource):
# All art sources. The order they will be tried in is specified by the config.
ART_SOURCES: set[Type[ArtSource]] = {
ART_SOURCES: set[type[ArtSource]] = {
FileSystem,
CoverArtArchive,
ITunesStore,

View file

@ -19,15 +19,17 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from beets import plugins, ui
from beets import config, plugins, ui
if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.library import Album, Item
def split_on_feat(
artist: str, for_artist: bool = True
artist: str,
for_artist: bool = True,
custom_words: list[str] | None = None,
) -> tuple[str, str | None]:
"""Given an artist string, split the "main" artist from any artist
on the right-hand side of a string like "feat". Return the main
@ -35,7 +37,9 @@ def split_on_feat(
may be a string or None if none is present.
"""
# split on the first "feat".
regex = re.compile(plugins.feat_tokens(for_artist), re.IGNORECASE)
regex = re.compile(
plugins.feat_tokens(for_artist, custom_words), re.IGNORECASE
)
parts = tuple(s.strip() for s in regex.split(artist, 1))
if len(parts) == 1:
return parts[0], None
@ -44,18 +48,22 @@ def split_on_feat(
return parts
def contains_feat(title: str) -> bool:
def contains_feat(title: str, custom_words: list[str] | None = None) -> bool:
"""Determine whether the title contains a "featured" marker."""
return bool(
re.search(
plugins.feat_tokens(for_artist=False),
plugins.feat_tokens(for_artist=False, custom_words=custom_words),
title,
flags=re.IGNORECASE,
)
)
def find_feat_part(artist: str, albumartist: str | None) -> str | None:
def find_feat_part(
artist: str,
albumartist: str | None,
custom_words: list[str] | None = None,
) -> str | None:
"""Attempt to find featured artists in the item's artist fields and
return the results. Returns None if no featured artist found.
"""
@ -69,23 +77,32 @@ def find_feat_part(artist: str, albumartist: str | None) -> str | None:
# featured artist.
if albumartist_split[1] != "":
# Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(albumartist_split[1])
_, feat_part = split_on_feat(
albumartist_split[1], custom_words=custom_words
)
return feat_part
# Otherwise, if there's nothing on the right-hand side,
# look for a featuring artist on the left-hand side.
else:
lhs, _ = split_on_feat(albumartist_split[0])
lhs, _ = split_on_feat(
albumartist_split[0], custom_words=custom_words
)
if lhs:
return lhs
# Fall back to conservative handling of the track artist without relying
# on albumartist, which covers compilations using a 'Various Artists'
# albumartist and album tracks by a guest artist featuring a third artist.
_, feat_part = split_on_feat(artist, False)
_, feat_part = split_on_feat(artist, False, custom_words)
return feat_part
def _album_artist_no_feat(album: Album) -> str:
custom_words = config["ftintitle"]["custom_words"].as_str_seq()
return split_on_feat(album["albumartist"], False, list(custom_words))[0]
class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self) -> None:
super().__init__()
@ -96,6 +113,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"drop": False,
"format": "feat. {}",
"keep_in_artist": False,
"preserve_album_artist": True,
"custom_words": [],
}
)
@ -115,15 +134,29 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if self.config["auto"]:
self.import_stages = [self.imported]
self.album_template_fields["album_artist_no_feat"] = (
_album_artist_no_feat
)
def commands(self) -> list[ui.Subcommand]:
def func(lib, opts, args):
self.config.set_args(opts)
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
preserve_album_artist = self.config["preserve_album_artist"].get(
bool
)
custom_words = self.config["custom_words"].get(list)
write = ui.should_write()
for item in lib.items(args):
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
if self.ft_in_title(
item,
drop_feat,
keep_in_artist_field,
preserve_album_artist,
custom_words,
):
item.store()
if write:
item.try_write()
@ -135,9 +168,17 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"""Import hook for moving featuring artist automatically."""
drop_feat = self.config["drop"].get(bool)
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
preserve_album_artist = self.config["preserve_album_artist"].get(bool)
custom_words = self.config["custom_words"].get(list)
for item in task.imported_items():
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
if self.ft_in_title(
item,
drop_feat,
keep_in_artist_field,
preserve_album_artist,
custom_words,
):
item.store()
def update_metadata(
@ -146,6 +187,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
feat_part: str,
drop_feat: bool,
keep_in_artist_field: bool,
custom_words: list[str],
) -> None:
"""Choose how to add new artists to the title and set the new
metadata. Also, print out messages about any changes that are made.
@ -158,17 +200,21 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
"artist: {.artist} (Not changing due to keep_in_artist)", item
)
else:
track_artist, _ = split_on_feat(item.artist)
track_artist, _ = split_on_feat(
item.artist, custom_words=custom_words
)
self._log.info("artist: {0.artist} -> {1}", item, track_artist)
item.artist = track_artist
if item.artist_sort:
# Just strip the featured artist from the sort name.
item.artist_sort, _ = split_on_feat(item.artist_sort)
item.artist_sort, _ = split_on_feat(
item.artist_sort, custom_words=custom_words
)
# Only update the title if it does not already contain a featured
# artist and if we do not drop featuring information.
if not drop_feat and not contains_feat(item.title):
if not drop_feat and not contains_feat(item.title, custom_words):
feat_format = self.config["format"].as_str()
new_format = feat_format.format(feat_part)
new_title = f"{item.title} {new_format}"
@ -180,6 +226,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
item: Item,
drop_feat: bool,
keep_in_artist_field: bool,
preserve_album_artist: bool,
custom_words: list[str],
) -> bool:
"""Look for featured artists in the item's artist fields and move
them to the title.
@ -193,22 +241,24 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# Check whether there is a featured artist on this track and the
# artist field does not exactly match the album artist field. In
# that case, we attempt to move the featured artist to the title.
if albumartist and artist == albumartist:
if preserve_album_artist and albumartist and artist == albumartist:
return False
_, featured = split_on_feat(artist)
_, featured = split_on_feat(artist, custom_words=custom_words)
if not featured:
return False
self._log.info("{.filepath}", item)
# Attempt to find the featured artist.
feat_part = find_feat_part(artist, albumartist)
feat_part = find_feat_part(artist, albumartist, custom_words)
if not feat_part:
self._log.info("no featuring artists found")
return False
# If we have a featuring artist, move it to the title.
self.update_metadata(item, feat_part, drop_feat, keep_in_artist_field)
self.update_metadata(
item, feat_part, drop_feat, keep_in_artist_field, custom_words
)
return True

View file

@ -1,27 +0,0 @@
# 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.
"""Deprecation warning for the removed gmusic plugin."""
from beets.plugins import BeetsPlugin
class Gmusic(BeetsPlugin):
def __init__(self):
super().__init__()
self._log.warning(
"The 'gmusic' plugin has been removed following the"
" shutdown of Google Play Music. Remove the plugin"
" from your configuration to silence this warning."
)

167
beetsplug/importsource.py Normal file
View file

@ -0,0 +1,167 @@
"""Adds a `source_path` attribute to imported albums indicating from what path
the album was imported from. Also suggests removing that source path in case
you've removed the album from the library.
"""
import os
from pathlib import Path
from shutil import rmtree
from beets.dbcore.query import PathQuery
from beets.plugins import BeetsPlugin
from beets.ui import colorize as colorize_text
from beets.ui import input_options
class ImportSourcePlugin(BeetsPlugin):
"""Main plugin class."""
def __init__(self):
"""Initialize the plugin and read configuration."""
super().__init__()
self.config.add(
{
"suggest_removal": False,
}
)
self.import_stages = [self.import_stage]
self.register_listener("item_removed", self.suggest_removal)
# In order to stop future removal suggestions for an album we keep
# track of `mb_albumid`s in this set.
self.stop_suggestions_for_albums = set()
# During reimports (import --library) both the import_task_choice and
# the item_removed event are triggered. The item_removed event is
# triggered first. For the import_task_choice event we prevent removal
# suggestions using the existing stop_suggestions_for_album mechanism.
self.register_listener(
"import_task_choice", self.prevent_suggest_removal
)
def prevent_suggest_removal(self, session, task):
for item in task.imported_items():
if "mb_albumid" in item:
self.stop_suggestions_for_albums.add(item.mb_albumid)
def import_stage(self, _, task):
"""Event handler for albums import finished."""
for item in task.imported_items():
# During reimports (import --library), we prevent overwriting the
# source_path attribute with the path from the music library
if "source_path" in item:
self._log.info(
"Preserving source_path of reimported item {}", item.id
)
continue
item["source_path"] = item.path
item.try_sync(write=False, move=False)
def suggest_removal(self, item):
"""Prompts the user to delete the original path the item was imported from."""
if (
not self.config["suggest_removal"]
or item.mb_albumid in self.stop_suggestions_for_albums
):
return
if "source_path" not in item:
self._log.warning(
"Item without source_path (probably imported before plugin "
"usage): {}",
item.filepath,
)
return
srcpath = Path(os.fsdecode(item.source_path))
if not srcpath.is_file():
self._log.warning(
"Original source file no longer exists or is not accessible: {}",
srcpath,
)
return
if not (
os.access(srcpath, os.W_OK)
and os.access(srcpath.parent, os.W_OK | os.X_OK)
):
self._log.warning(
"Original source file cannot be deleted (insufficient permissions): {}",
srcpath,
)
return
# We ask the user whether they'd like to delete the item's source
# directory
item_path = colorize_text("text_warning", item.filepath)
source_path = colorize_text("text_warning", srcpath)
print(
f"The item:\n{item_path}\nis originated from:\n{source_path}\n"
"What would you like to do?"
)
resp = input_options(
[
"Delete the item's source",
"Recursively delete the source's directory",
"do Nothing",
"do nothing and Stop suggesting to delete items from this album",
],
require=True,
)
# Handle user response
if resp == "d":
self._log.info(
"Deleting the item's source file: {}",
srcpath,
)
srcpath.unlink()
elif resp == "r":
self._log.info(
"Searching for other items with a source_path attr containing: {}",
srcpath.parent,
)
source_dir_query = PathQuery(
"source_path",
srcpath.parent,
# The "source_path" attribute may not be present in all
# items of the library, so we avoid errors with this:
fast=False,
)
print("Doing so will delete the following items' sources as well:")
for searched_item in item._db.items(source_dir_query):
print(colorize_text("text_warning", searched_item.filepath))
print("Would you like to continue?")
continue_resp = input_options(
["Yes", "delete None", "delete just the File"],
require=False, # Yes is the a default
)
if continue_resp == "y":
self._log.info(
"Deleting the item's source directory: {}",
srcpath.parent,
)
rmtree(srcpath.parent)
elif continue_resp == "n":
self._log.info("doing nothing - aborting hook function")
return
elif continue_resp == "f":
self._log.info(
"removing just the item's original source: {}",
srcpath,
)
srcpath.unlink()
elif resp == "s":
self.stop_suggestions_for_albums.add(item.mb_albumid)
else:
self._log.info("Doing nothing")

View file

@ -61,18 +61,18 @@ class InlinePlugin(BeetsPlugin):
config["item_fields"].items(), config["pathfields"].items()
):
self._log.debug("adding item field {}", key)
func = self.compile_inline(view.as_str(), False)
func = self.compile_inline(view.as_str(), False, key)
if func is not None:
self.template_fields[key] = func
# Album fields.
for key, view in config["album_fields"].items():
self._log.debug("adding album field {}", key)
func = self.compile_inline(view.as_str(), True)
func = self.compile_inline(view.as_str(), True, key)
if func is not None:
self.album_template_fields[key] = func
def compile_inline(self, python_code, album):
def compile_inline(self, python_code, album, field_name):
"""Given a Python expression or function body, compile it as a path
field function. The returned function takes a single argument, an
Item, and returns a Unicode string. If the expression cannot be
@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin):
is_expr = True
def _dict_for(obj):
out = dict(obj)
out = {}
for key in obj.keys(computed=False):
if key == field_name:
continue
out[key] = obj._get(key)
if album:
out["items"] = list(obj.items())
return out

View file

@ -22,10 +22,13 @@ The scraper script used is available here:
https://gist.github.com/1241307
"""
from __future__ import annotations
import os
import traceback
from functools import singledispatchmethod
from pathlib import Path
from typing import Union
from typing import TYPE_CHECKING
import pylast
import yaml
@ -34,6 +37,9 @@ from beets import config, library, plugins, ui
from beets.library import Album, Item
from beets.util import plurality, unique_list
if TYPE_CHECKING:
from beets.library import LibModel
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
PYLAST_EXCEPTIONS = (
@ -100,7 +106,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"separator": ", ",
"prefer_specific": False,
"title_case": True,
"extended_debug": False,
"pretend": False,
}
)
self.setup()
@ -155,6 +161,11 @@ class LastGenrePlugin(plugins.BeetsPlugin):
flatten_tree(genres_tree, [], c14n_branches)
return c14n_branches, canonicalize
def _tunelog(self, msg, *args, **kwargs):
"""Log tuning messages at DEBUG level when verbosity level is high enough."""
if config["verbose"].as_number() >= 3:
self._log.debug(msg, *args, **kwargs)
@property
def sources(self) -> tuple[str, ...]:
"""A tuple of allowed genre sources. May contain 'track',
@ -286,8 +297,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
self._genre_cache[key] = self.fetch_genre(method(*args))
genre = self._genre_cache[key]
if self.config["extended_debug"]:
self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre)
self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre)
return genre
def fetch_album_genre(self, obj):
@ -321,7 +331,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return self.config["separator"].as_str().join(formatted)
def _get_existing_genres(self, obj: Union[Album, Item]) -> list[str]:
def _get_existing_genres(self, obj: LibModel) -> list[str]:
"""Return a list of genres for this Item or Album. Empty string genres
are removed."""
separator = self.config["separator"].get()
@ -342,9 +352,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
combined = old + new
return self._resolve_genres(combined)
def _get_genre(
self, obj: Union[Album, Item]
) -> tuple[Union[str, None], ...]:
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
"""Get the final genre string for an Album or Item object.
`self.sources` specifies allowed genre sources. Starting with the first
@ -459,6 +467,39 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# Beets plugin hooks and CLI.
def _fetch_and_log_genre(self, obj: LibModel) -> None:
"""Fetch genre and log it."""
self._log.info(str(obj))
obj.genre, label = self._get_genre(obj)
self._log.debug("Resolved ({}): {}", label, obj.genre)
ui.show_model_changes(obj, fields=["genre"], print_obj=False)
@singledispatchmethod
def _process(self, obj: LibModel, write: bool) -> None:
"""Process an object, dispatching to the appropriate method."""
raise NotImplementedError
@_process.register
def _process_track(self, obj: Item, write: bool) -> None:
"""Process a single track/item."""
self._fetch_and_log_genre(obj)
if not self.config["pretend"]:
obj.try_sync(write=write, move=False)
@_process.register
def _process_album(self, obj: Album, write: bool) -> None:
"""Process an entire album."""
self._fetch_and_log_genre(obj)
if "track" in self.sources:
for item in obj.items():
self._process(item, write)
if not self.config["pretend"]:
obj.try_sync(
write=write, move=False, inherit="track" not in self.sources
)
def commands(self):
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
lastgenre_cmd.parser.add_option(
@ -516,111 +557,20 @@ class LastGenrePlugin(plugins.BeetsPlugin):
dest="album",
help="match albums instead of items (default)",
)
lastgenre_cmd.parser.add_option(
"-d",
"--debug",
action="store_true",
dest="extended_debug",
help="extended last.fm debug logging",
)
lastgenre_cmd.parser.set_defaults(album=True)
def lastgenre_func(lib, opts, args):
write = ui.should_write()
pretend = getattr(opts, "pretend", False)
self.config.set_args(opts)
if opts.album:
# Fetch genres for whole albums
for album in lib.albums(args):
album_genre, src = self._get_genre(album)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for album "{.album}" ({}): {}',
prefix,
album,
src,
album_genre,
)
if not pretend:
album.genre = album_genre
if "track" in self.sources:
album.store(inherit=False)
else:
album.store()
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if "track" in self.sources:
item_genre, src = self._get_genre(item)
self._log.info(
'{}genre for track "{.title}" ({}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
else:
# Just query singletons, i.e. items that are not part of
# an album
for item in lib.items(args):
item_genre, src = self._get_genre(item)
prefix = "Pretend: " if pretend else ""
self._log.info(
'{}genre for track "{0.title}" ({1}): {}',
prefix,
item,
src,
item_genre,
)
if not pretend:
item.genre = item_genre
item.store()
if write and not pretend:
item.try_write()
method = lib.albums if opts.album else lib.items
for obj in method(args):
self._process(obj, write=ui.should_write())
lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd]
def imported(self, session, task):
"""Event hook called when an import task finishes."""
if task.is_album:
album = task.album
album.genre, src = self._get_genre(album)
self._log.debug(
'genre for album "{0.album}" ({1}): {0.genre}', album, src
)
# If we're using track-level sources, store the album genre only,
# then also look up individual track genres.
if "track" in self.sources:
album.store(inherit=False)
for item in album.items():
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}',
item,
src,
)
item.store()
# Store the album genre and inherit to tracks.
else:
album.store()
else:
item = task.item
item.genre, src = self._get_genre(item)
self._log.debug(
'genre for track "{0.title}" ({1}): {0.genre}', item, src
)
item.store()
self._process(task.album if task.is_album else task.item, write=False)
def _tags_for(self, obj, min_weight=None):
"""Core genre identification routine.

View file

@ -28,7 +28,7 @@ from html import unescape
from http import HTTPStatus
from itertools import groupby
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
from typing import TYPE_CHECKING, NamedTuple
from urllib.parse import quote, quote_plus, urlencode, urlparse
import langdetect
@ -42,6 +42,8 @@ from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from beets.importer import ImportTask
from beets.library import Item, Library
from beets.logging import BeetsLogger as Logger
@ -745,7 +747,9 @@ class Translator(RequestHandler):
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
SEPARATOR = " | "
remove_translations = partial(re.compile(r" / [^\n]+").sub, "")
remove_translations = staticmethod(
partial(re.compile(r" / [^\n]+").sub, "")
)
_log: Logger
api_key: str
@ -956,7 +960,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
@cached_property
def backends(self) -> list[Backend]:
user_sources = self.config["sources"].get()
user_sources = self.config["sources"].as_str_seq()
chosen = sanitize_choices(user_sources, self.BACKEND_BY_NAME)
if "google" in chosen and not self.config["google_API_key"].get():

366
beetsplug/mbpseudo.py Normal file
View file

@ -0,0 +1,366 @@
# This file is part of beets.
# Copyright 2025, Alexis Sarda-Espinosa.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds pseudo-releases from MusicBrainz as candidates during import."""
from __future__ import annotations
import itertools
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any
import mediafile
import musicbrainzngs
from typing_extensions import override
from beets import config
from beets.autotag.distance import Distance, distance
from beets.autotag.hooks import AlbumInfo
from beets.autotag.match import assign_items
from beets.plugins import find_plugins
from beets.util.id_extractors import extract_release_id
from beetsplug.musicbrainz import (
RELEASE_INCLUDES,
MusicBrainzAPIError,
MusicBrainzPlugin,
_merge_pseudo_and_actual_album,
_preferred_alias,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag import AlbumMatch
from beets.library import Item
from beetsplug._typing import JSONDict
_STATUS_PSEUDO = "Pseudo-Release"
class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
def __init__(self) -> None:
super().__init__()
self._release_getter = musicbrainzngs.get_release_by_id
self.config.add(
{
"scripts": [],
"custom_tags_only": False,
"album_custom_tags": {
"album_transl": "album",
"album_artist_transl": "artist",
},
"track_custom_tags": {
"title_transl": "title",
"artist_transl": "artist",
},
}
)
self._scripts = self.config["scripts"].as_str_seq()
self._log.debug("Desired scripts: {0}", self._scripts)
album_custom_tags = self.config["album_custom_tags"].get().keys()
track_custom_tags = self.config["track_custom_tags"].get().keys()
self._log.debug(
"Custom tags for albums and tracks: {0} + {1}",
album_custom_tags,
track_custom_tags,
)
for custom_tag in album_custom_tags | track_custom_tags:
if not isinstance(custom_tag, str):
continue
media_field = mediafile.MediaField(
mediafile.MP3DescStorageStyle(custom_tag),
mediafile.MP4StorageStyle(
f"----:com.apple.iTunes:{custom_tag}"
),
mediafile.StorageStyle(custom_tag),
mediafile.ASFStorageStyle(custom_tag),
)
try:
self.add_media_field(custom_tag, media_field)
except ValueError:
# ignore errors due to duplicates
pass
self.register_listener("pluginload", self._on_plugins_loaded)
self.register_listener("album_matched", self._adjust_final_album_match)
# noinspection PyMethodMayBeStatic
def _on_plugins_loaded(self):
for plugin in find_plugins():
if isinstance(plugin, MusicBrainzPlugin) and not isinstance(
plugin, MusicBrainzPseudoReleasePlugin
):
raise RuntimeError(
"The musicbrainz plugin should not be enabled together with"
" the mbpseudo plugin"
)
@override
def candidates(
self,
items: Sequence[Item],
artist: str,
album: str,
va_likely: bool,
) -> Iterable[AlbumInfo]:
if len(self._scripts) == 0:
yield from super().candidates(items, artist, album, va_likely)
else:
for album_info in super().candidates(
items, artist, album, va_likely
):
if isinstance(album_info, PseudoAlbumInfo):
self._log.debug(
"Using {0} release for distance calculations for album {1}",
album_info.determine_best_ref(items),
album_info.album_id,
)
yield album_info # first yield pseudo to give it priority
yield album_info.get_official_release()
else:
yield album_info
@override
def album_info(self, release: JSONDict) -> AlbumInfo:
official_release = super().album_info(release)
if release.get("status") == _STATUS_PSEUDO:
return official_release
elif pseudo_release_ids := self._intercept_mb_release(release):
album_id = self._extract_id(pseudo_release_ids[0])
try:
raw_pseudo_release = self._release_getter(
album_id, RELEASE_INCLUDES
)["release"]
pseudo_release = super().album_info(raw_pseudo_release)
if self.config["custom_tags_only"].get(bool):
self._replace_artist_with_alias(
raw_pseudo_release, pseudo_release
)
self._add_custom_tags(official_release, pseudo_release)
return official_release
else:
return PseudoAlbumInfo(
pseudo_release=_merge_pseudo_and_actual_album(
pseudo_release, official_release
),
official_release=official_release,
)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(
exc,
"get pseudo-release by ID",
album_id,
traceback.format_exc(),
)
else:
return official_release
def _intercept_mb_release(self, data: JSONDict) -> list[str]:
album_id = data["id"] if "id" in data else None
if self._has_desired_script(data) or not isinstance(album_id, str):
return []
return [
pr_id
for rel in data.get("release-relation-list", [])
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
is not None
]
def _has_desired_script(self, release: JSONDict) -> bool:
if len(self._scripts) == 0:
return False
elif script := release.get("text-representation", {}).get("script"):
return script in self._scripts
else:
return False
def _wanted_pseudo_release_id(
self,
album_id: str,
relation: JSONDict,
) -> str | None:
if (
len(self._scripts) == 0
or relation.get("type", "") != "transl-tracklisting"
or relation.get("direction", "") != "forward"
or "release" not in relation
):
return None
release = relation["release"]
if "id" in release and self._has_desired_script(release):
self._log.debug(
"Adding pseudo-release {0} for main release {1}",
release["id"],
album_id,
)
return release["id"]
else:
return None
def _replace_artist_with_alias(
self,
raw_pseudo_release: JSONDict,
pseudo_release: AlbumInfo,
):
"""Use the pseudo-release's language to search for artist
alias if the user hasn't configured import languages."""
if len(config["import"]["languages"].as_str_seq()) > 0:
return
lang = raw_pseudo_release.get("text-representation", {}).get("language")
artist_credits = raw_pseudo_release.get("release-group", {}).get(
"artist-credit", []
)
aliases = [
artist_credit.get("artist", {}).get("alias-list", [])
for artist_credit in artist_credits
]
if lang and len(lang) >= 2 and len(aliases) > 0:
locale = lang[0:2]
aliases_flattened = list(itertools.chain.from_iterable(aliases))
self._log.debug(
"Using locale '{0}' to search aliases {1}",
locale,
aliases_flattened,
)
if alias_dict := _preferred_alias(aliases_flattened, [locale]):
if alias := alias_dict.get("alias"):
self._log.debug("Got alias '{0}'", alias)
pseudo_release.artist = alias
for track in pseudo_release.tracks:
track.artist = alias
def _add_custom_tags(
self,
official_release: AlbumInfo,
pseudo_release: AlbumInfo,
):
for tag_key, pseudo_key in (
self.config["album_custom_tags"].get().items()
):
official_release[tag_key] = pseudo_release[pseudo_key]
track_custom_tags = self.config["track_custom_tags"].get().items()
for track, pseudo_track in zip(
official_release.tracks, pseudo_release.tracks
):
for tag_key, pseudo_key in track_custom_tags:
track[tag_key] = pseudo_track[pseudo_key]
def _adjust_final_album_match(self, match: AlbumMatch):
album_info = match.info
if isinstance(album_info, PseudoAlbumInfo):
self._log.debug(
"Switching {0} to pseudo-release source for final proposal",
album_info.album_id,
)
album_info.use_pseudo_as_ref()
mapping = match.mapping
new_mappings, _, _ = assign_items(
list(mapping.keys()), album_info.tracks
)
mapping.update(new_mappings)
if album_info.data_source == self.data_source:
album_info.data_source = "MusicBrainz"
@override
def _extract_id(self, url: str) -> str | None:
return extract_release_id("MusicBrainz", url)
class PseudoAlbumInfo(AlbumInfo):
"""This is a not-so-ugly hack.
We want the pseudo-release to result in a distance that is lower or equal to that of
the official release, otherwise it won't qualify as a good candidate. However, if
the input is in a script that's different from the pseudo-release (and we want to
translate/transliterate it in the library), it will receive unwanted penalties.
This class is essentially a view of the ``AlbumInfo`` of both official and
pseudo-releases, where it's possible to change the details that are exposed to other
parts of the auto-tagger, enabling a "fair" distance calculation based on the
current input's script but still preferring the translation/transliteration in the
final proposal.
"""
def __init__(
self,
pseudo_release: AlbumInfo,
official_release: AlbumInfo,
**kwargs,
):
super().__init__(pseudo_release.tracks, **kwargs)
self.__dict__["_pseudo_source"] = True
self.__dict__["_official_release"] = official_release
for k, v in pseudo_release.items():
if k not in kwargs:
self[k] = v
def get_official_release(self) -> AlbumInfo:
return self.__dict__["_official_release"]
def determine_best_ref(self, items: Sequence[Item]) -> str:
self.use_pseudo_as_ref()
pseudo_dist = self._compute_distance(items)
self.use_official_as_ref()
official_dist = self._compute_distance(items)
if official_dist < pseudo_dist:
self.use_official_as_ref()
return "official"
else:
self.use_pseudo_as_ref()
return "pseudo"
def _compute_distance(self, items: Sequence[Item]) -> Distance:
mapping, _, _ = assign_items(items, self.tracks)
return distance(items, self, mapping)
def use_pseudo_as_ref(self):
self.__dict__["_pseudo_source"] = True
def use_official_as_ref(self):
self.__dict__["_pseudo_source"] = False
def __getattr__(self, attr: str) -> Any:
# ensure we don't duplicate an official release's id, always return pseudo's
if self.__dict__["_pseudo_source"] or attr == "album_id":
return super().__getattr__(attr)
else:
return self.__dict__["_official_release"].__getattr__(attr)
def __deepcopy__(self, memo):
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result.__dict__.update(self.__dict__)
for k, v in self.items():
result[k] = deepcopy(v, memo)
return result

View file

@ -26,8 +26,7 @@ import subprocess
from beets import ui
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beets.util import displayable_path
from beets.util import PromptChoice, displayable_path
from beetsplug.info import print_data

View file

@ -21,7 +21,7 @@ from collections import Counter
from contextlib import suppress
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING, Any, Iterable, Sequence
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import musicbrainzngs
@ -31,9 +31,11 @@ import beets
import beets.autotag.hooks
from beets import config, plugins, util
from beets.metadata_plugins import MetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Literal
from beets.library import Item
@ -89,6 +91,7 @@ RELEASE_INCLUDES = list(
"isrcs",
"url-rels",
"release-rels",
"genres",
"tags",
}
& set(musicbrainzngs.VALID_INCLUDES["release"])
@ -118,13 +121,15 @@ BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
def _preferred_alias(aliases: list[JSONDict]):
"""Given an list of alias structures for an artist credit, select
and return the user's preferred alias alias or None if no matching
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
) -> JSONDict | None:
"""Given a list of alias structures for an artist credit, select
and return the user's preferred alias or None if no matching
alias is found.
"""
if not aliases:
return
return None
# Only consider aliases that have locales set.
valid_aliases = [a for a in aliases if "locale" in a]
@ -134,7 +139,10 @@ def _preferred_alias(aliases: list[JSONDict]):
ignored_alias_types = [a.lower() for a in ignored_alias_types]
# Search configured locales in order.
for locale in config["import"]["languages"].as_str_seq():
if languages is None:
languages = config["import"]["languages"].as_str_seq()
for locale in languages:
# Find matching primary aliases for this locale that are not
# being ignored
matches = []
@ -152,6 +160,8 @@ def _preferred_alias(aliases: list[JSONDict]):
return matches[0]
return None
def _multi_artist_credit(
credit: list[JSONDict], include_join_phrase: bool
@ -323,7 +333,7 @@ def _find_actual_release_from_pseudo_release(
def _merge_pseudo_and_actual_album(
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
) -> beets.autotag.hooks.AlbumInfo | None:
) -> beets.autotag.hooks.AlbumInfo:
"""
Merges a pseudo release with its actual release.
@ -362,6 +372,10 @@ def _merge_pseudo_and_actual_album(
class MusicBrainzPlugin(MetadataSourcePlugin):
@cached_property
def genres_field(self) -> str:
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list"
def __init__(self):
"""Set up the python-musicbrainz-ngs module according to settings
from the beets configuration. This should be called at startup.
@ -374,6 +388,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
"ratelimit": 1,
"ratelimit_interval": 1,
"genres": False,
"genres_tag": "genre",
"external_ids": {
"discogs": False,
"bandcamp": False,
@ -389,9 +404,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
self.config["search_limit"] = self.config["match"][
"searchlimit"
].get()
self._log.warning(
"'musicbrainz.searchlimit' option is deprecated and will be "
"removed in 3.0.0. Use 'musicbrainz.search_limit' instead."
deprecate_for_user(
self._log,
"'musicbrainz.searchlimit' configuration option",
"'musicbrainz.search_limit'",
)
hostname = self.config["host"].as_str()
https = self.config["https"].get(bool)
@ -715,8 +731,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
if self.config["genres"]:
sources = [
release["release-group"].get("tag-list", []),
release.get("tag-list", []),
release["release-group"].get(self.genres_field, []),
release.get(self.genres_field, []),
]
genres: Counter[str] = Counter()
for source in sources:

View file

@ -21,13 +21,17 @@ from os.path import relpath
from beets import config, ui, util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.ui.commands import PromptChoice
from beets.util import get_temp_filename
from beets.util import PromptChoice, get_temp_filename
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
ARGS_MARKER = "$args"
# Indicate where the playlist file (with absolute path) should be inserted into
# the command string. If this is missing, its placed at the end, but before
# arguments.
PLS_MARKER = "$playlist"
def play(
command_str,
@ -132,8 +136,23 @@ class PlayPlugin(BeetsPlugin):
return
open_args = self._playlist_or_paths(paths)
open_args_str = [
p.decode("utf-8") for p in self._playlist_or_paths(paths)
]
command_str = self._command_str(opts.args)
if PLS_MARKER in command_str:
if not config["play"]["raw"]:
command_str = command_str.replace(
PLS_MARKER, "".join(open_args_str)
)
self._log.debug(
"command altered by PLS_MARKER to: {}", command_str
)
open_args = []
else:
command_str = command_str.replace(PLS_MARKER, " ")
# Check if the selection exceeds configured threshold. If True,
# cancel, otherwise proceed with play command.
if opts.yes or not self._exceeds_threshold(
@ -162,6 +181,7 @@ class PlayPlugin(BeetsPlugin):
return paths
else:
return [self._create_tmp_playlist(paths)]
return [shlex.quote(self._create_tmp_playlist(paths))]
def _exceeds_threshold(
self, selection, command_str, open_args, item_type="track"

View file

@ -28,7 +28,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from multiprocessing.pool import ThreadPool
from threading import Event, Thread
from typing import TYPE_CHECKING, Any, Callable, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar
from beets import ui
from beets.plugins import BeetsPlugin
@ -36,7 +36,7 @@ from beets.util import command_output, displayable_path, syspath
if TYPE_CHECKING:
import optparse
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from logging import Logger
from confuse import ConfigView

View file

@ -13,8 +13,9 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds Spotify release and track search support to the autotagger, along with
Spotify playlist construction.
"""Adds Spotify release and track search support to the autotagger.
Also includes Spotify playlist construction.
"""
from __future__ import annotations
@ -23,9 +24,10 @@ import base64
import collections
import json
import re
import threading
import time
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
from typing import TYPE_CHECKING, Any, Literal, Union
import confuse
import requests
@ -41,6 +43,8 @@ from beets.metadata_plugins import (
)
if TYPE_CHECKING:
from collections.abc import Sequence
from beets.library import Library
from beetsplug._typing import JSONDict
@ -50,13 +54,14 @@ DEFAULT_WAITING_TIME = 5
class SearchResponseAlbums(IDResponse):
"""A response returned by the Spotify API.
We only use items and disregard the pagination information.
i.e. res["albums"]["items"][0].
We only use items and disregard the pagination information. i.e.
res["albums"]["items"][0].
There are more fields in the response, but we only type
the ones we currently use.
There are more fields in the response, but we only type the ones we
currently use.
see https://developer.spotify.com/documentation/web-api/reference/search
"""
album_type: str
@ -77,6 +82,12 @@ class APIError(Exception):
pass
class AudioFeaturesUnavailableError(Exception):
"""Raised when audio features API returns 403 (deprecated)."""
pass
class SpotifyPlugin(
SearchApiMetadataSourcePlugin[
Union[SearchResponseAlbums, SearchResponseTracks]
@ -140,6 +151,12 @@ class SpotifyPlugin(
self.config["client_id"].redact = True
self.config["client_secret"].redact = True
self.audio_features_available = (
True # Track if audio features API is available
)
self._audio_features_lock = (
threading.Lock()
) # Protects audio_features_available
self.setup()
def setup(self):
@ -158,9 +175,7 @@ class SpotifyPlugin(
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def _authenticate(self) -> None:
"""Request an access token via the Client Credentials Flow:
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
"""
"""Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow"""
c_id: str = self.config["client_id"].as_str()
c_secret: str = self.config["client_secret"].as_str()
@ -201,9 +216,9 @@ class SpotifyPlugin(
:param method: HTTP method to use for the request.
:param url: URL for the new :class:`Request` object.
:param params: (optional) list of tuples or bytes to send
:param dict params: (optional) list of tuples or bytes to send
in the query string for the :class:`Request`.
:type params: dict
"""
if retry_count > max_retries:
@ -246,6 +261,17 @@ class SpotifyPlugin(
f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}"
)
elif e.response.status_code == 403:
# Check if this is the audio features endpoint
if url.startswith(self.audio_features_url):
raise AudioFeaturesUnavailableError(
"Audio features API returned 403 "
"(deprecated or unavailable)"
)
raise APIError(
f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}"
)
elif e.response.status_code == 429:
seconds = e.response.headers.get(
"Retry-After", DEFAULT_WAITING_TIME
@ -268,7 +294,8 @@ class SpotifyPlugin(
raise APIError("Bad Gateway.")
elif e.response is not None:
raise APIError(
f"{self.data_source} API error:\n{e.response.text}\n"
f"{self.data_source} API error:\n"
f"{e.response.text}\n"
f"URL:\n{url}\nparams:\n{params}"
)
else:
@ -279,10 +306,11 @@ class SpotifyPlugin(
"""Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found.
:param album_id: Spotify ID or URL for the album
:type album_id: str
:return: AlbumInfo object for album
:param str album_id: Spotify ID or URL for the album
:returns: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
if not (spotify_id := self._extract_id(album_id)):
return None
@ -356,7 +384,9 @@ class SpotifyPlugin(
:param track_data: Simplified track object
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:return: TrackInfo object for track
:returns: TrackInfo object for track
"""
artist, artist_id = self.get_artist(track_data["artists"])
@ -385,6 +415,7 @@ class SpotifyPlugin(
"""Fetch a track by its Spotify ID or URL.
Returns a TrackInfo object or None if the track is not found.
"""
if not (spotify_id := self._extract_id(track_id)):
@ -425,10 +456,11 @@ class SpotifyPlugin(
"""Query the Spotify Search API for the specified ``query_string``,
applying the provided ``filters``.
:param query_type: Item type to search across. Valid types are:
'album', 'artist', 'playlist', and 'track'.
:param query_type: Item type to search across. Valid types are: 'album',
'artist', 'playlist', and 'track'.
:param filters: Field filters to apply.
:param query_string: Additional query to include in the search.
"""
query = self._construct_search_query(
filters=filters, query_string=query_string
@ -523,13 +555,16 @@ class SpotifyPlugin(
return True
def _match_library_tracks(self, library: Library, keywords: str):
"""Get a list of simplified track object dicts for library tracks
matching the specified ``keywords``.
"""Get simplified track object dicts for library tracks.
Matches tracks based on the specified ``keywords``.
:param library: beets library object to query.
:param keywords: Query to match library items against.
:return: List of simplified track object dicts for library items
matching the specified query.
:returns: List of simplified track object dicts for library
items matching the specified query.
"""
results = []
failures = []
@ -640,12 +675,14 @@ class SpotifyPlugin(
return results
def _output_match_results(self, results):
"""Open a playlist or print Spotify URLs for the provided track
object dicts.
"""Open a playlist or print Spotify URLs.
Uses the provided track object dicts.
:param list[dict] results: List of simplified track object dicts
(https://developer.spotify.com/documentation/web-api/
reference/object-model/#track-object-simplified)
:param results: List of simplified track object dicts
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:type results: list[dict]
"""
if results:
spotify_ids = [track_data["id"] for track_data in results]
@ -691,6 +728,8 @@ class SpotifyPlugin(
item["isrc"] = isrc
item["ean"] = ean
item["upc"] = upc
if self.audio_features_available:
audio_features = self.track_audio_features(spotify_track_id)
if audio_features is None:
self._log.info("No audio features found for: {}", item)
@ -698,6 +737,9 @@ class SpotifyPlugin(
for feature, value in audio_features.items():
if feature in self.spotify_audio_features:
item[self.spotify_audio_features[feature]] = value
else:
self._log.debug("Audio features API unavailable, skipping")
item["spotify_updated"] = time.time()
item.store()
if write:
@ -721,11 +763,34 @@ class SpotifyPlugin(
)
def track_audio_features(self, track_id: str):
"""Fetch track audio features by its Spotify ID."""
"""Fetch track audio features by its Spotify ID.
Thread-safe: avoids redundant API calls and logs the 403 warning only
once.
"""
# Fast path: if we've already detected unavailability, skip the call.
with self._audio_features_lock:
if not self.audio_features_available:
return None
try:
return self._handle_response(
"get", f"{self.audio_features_url}{track_id}"
)
except AudioFeaturesUnavailableError:
# Disable globally in a thread-safe manner and warn once.
should_log = False
with self._audio_features_lock:
if self.audio_features_available:
self.audio_features_available = False
should_log = True
if should_log:
self._log.warning(
"Audio features API is unavailable (403 error). "
"Skipping audio features for remaining tracks."
)
return None
except APIError as e:
self._log.debug("Spotify API error: {}", e)
return None

236
beetsplug/titlecase.py Normal file
View file

@ -0,0 +1,236 @@
# This file is part of beets.
# Copyright 2025, Henry Oberholtzer
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Apply NYT manual of style title case rules, to text.
Title case logic is derived from the python-titlecase library.
Provides a template function and a tag modification function."""
import re
from functools import cached_property
from typing import TypedDict
from titlecase import titlecase
from beets import ui
from beets.autotag.hooks import AlbumInfo, Info
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.plugins import BeetsPlugin
__author__ = "henryoberholtzer@gmail.com"
__version__ = "1.0"
class PreservedText(TypedDict):
words: dict[str, str]
phrases: dict[str, re.Pattern[str]]
class TitlecasePlugin(BeetsPlugin):
def __init__(self) -> None:
super().__init__()
self.config.add(
{
"auto": True,
"preserve": [],
"fields": [],
"replace": [],
"seperators": [],
"force_lowercase": False,
"small_first_last": True,
"the_artist": True,
"after_choice": False,
}
)
"""
auto - Automatically apply titlecase to new import metadata.
preserve - Provide a list of strings with specific case requirements.
fields - Fields to apply titlecase to.
replace - List of pairs, first is the target, second is the replacement
seperators - Other characters to treat like periods.
force_lowercase - Lowercases the string before titlecasing.
small_first_last - If small characters should be cased at the start of strings.
the_artist - If the plugin infers the field to be an artist field
(e.g. the field contains "artist")
It will capitalize a lowercase The, helpful for the artist names
that start with 'The', like 'The Who' or 'The Talking Heads' when
they are not at the start of a string. Superceded by preserved phrases.
"""
# Register template function
self.template_funcs["titlecase"] = self.titlecase
# Register UI subcommands
self._command = ui.Subcommand(
"titlecase",
help="Apply titlecasing to metadata specified in config.",
)
if self.config["auto"].get(bool):
if self.config["after_choice"].get(bool):
self.import_stages = [self.imported]
else:
self.register_listener(
"trackinfo_received", self.received_info_handler
)
self.register_listener(
"albuminfo_received", self.received_info_handler
)
@cached_property
def force_lowercase(self) -> bool:
return self.config["force_lowercase"].get(bool)
@cached_property
def replace(self) -> list[tuple[str, str]]:
return self.config["replace"].as_pairs()
@cached_property
def the_artist(self) -> bool:
return self.config["the_artist"].get(bool)
@cached_property
def fields_to_process(self) -> set[str]:
fields = set(self.config["fields"].as_str_seq())
self._log.debug(f"fields: {', '.join(fields)}")
return fields
@cached_property
def preserve(self) -> PreservedText:
strings = self.config["preserve"].as_str_seq()
preserved: PreservedText = {"words": {}, "phrases": {}}
for s in strings:
if " " in s:
preserved["phrases"][s] = re.compile(
rf"\b{re.escape(s)}\b", re.IGNORECASE
)
else:
preserved["words"][s.upper()] = s
return preserved
@cached_property
def seperators(self) -> re.Pattern[str] | None:
if seperators := "".join(
dict.fromkeys(self.config["seperators"].as_str_seq())
):
return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)")
return None
@cached_property
def small_first_last(self) -> bool:
return self.config["small_first_last"].get(bool)
@cached_property
def the_artist_regexp(self) -> re.Pattern[str]:
return re.compile(r"\bthe\b")
def titlecase_callback(self, word, **kwargs) -> str | None:
"""Callback function for words to preserve case of."""
if preserved_word := self.preserve["words"].get(word.upper(), ""):
return preserved_word
return None
def received_info_handler(self, info: Info):
"""Calls titlecase fields for AlbumInfo or TrackInfo
Processes the tracks field for AlbumInfo
"""
self.titlecase_fields(info)
if isinstance(info, AlbumInfo):
for track in info.tracks:
self.titlecase_fields(track)
def commands(self) -> list[ui.Subcommand]:
def func(lib, opts, args):
write = ui.should_write()
for item in lib.items(args):
self._log.info(f"titlecasing {item.title}:")
self.titlecase_fields(item)
item.store()
if write:
item.try_write()
self._command.func = func
return [self._command]
def titlecase_fields(self, item: Item | Info) -> None:
"""Applies titlecase to fields, except
those excluded by the default exclusions and the
set exclude lists.
"""
for field in self.fields_to_process:
init_field = getattr(item, field, "")
if init_field:
if isinstance(init_field, list) and isinstance(
init_field[0], str
):
cased_list: list[str] = [
self.titlecase(i, field) for i in init_field
]
if cased_list != init_field:
setattr(item, field, cased_list)
self._log.info(
f"{field}: {', '.join(init_field)} ->",
f"{', '.join(cased_list)}",
)
elif isinstance(init_field, str):
cased: str = self.titlecase(init_field, field)
if cased != init_field:
setattr(item, field, cased)
self._log.info(f"{field}: {init_field} -> {cased}")
else:
self._log.debug(f"{field}: no string present")
else:
self._log.debug(f"{field}: does not exist on {type(item)}")
def titlecase(self, text: str, field: str = "") -> str:
"""Titlecase the given text."""
# Check we should split this into two substrings.
if self.seperators:
if len(splits := self.seperators.findall(text)):
split_cased = "".join(
[self.titlecase(s[0], field) + s[1] for s in splits]
)
# Add on the remaining portion
return split_cased + self.titlecase(
text[len(split_cased) :], field
)
# Any necessary replacements go first, mainly punctuation.
titlecased = text.lower() if self.force_lowercase else text
for pair in self.replace:
target, replacement = pair
titlecased = titlecased.replace(target, replacement)
# General titlecase operation
titlecased = titlecase(
titlecased,
small_first_last=self.small_first_last,
callback=self.titlecase_callback,
)
# Apply "The Artist" feature
if self.the_artist and "artist" in field:
titlecased = self.the_artist_regexp.sub("The", titlecased)
# More complicated phrase replacements.
for phrase, regexp in self.preserve["phrases"].items():
titlecased = regexp.sub(phrase, titlecased)
return titlecased
def imported(self, session: ImportSession, task: ImportTask) -> None:
"""Import hook for titlecasing on import."""
for item in task.imported_items():
try:
self._log.debug(f"titlecasing {item.title}:")
self.titlecase_fields(item)
item.store()
except Exception as e:
self._log.debug(f"titlecasing exception {e}")

View file

@ -17,9 +17,10 @@
import base64
import json
import os
import typing as t
import flask
from flask import g, jsonify
from flask import jsonify
from unidecode import unidecode
from werkzeug.routing import BaseConverter, PathConverter
@ -28,6 +29,17 @@ from beets import ui, util
from beets.dbcore.query import PathQuery
from beets.plugins import BeetsPlugin
# Type checking hacks
if t.TYPE_CHECKING:
class LibraryCtx(flask.ctx._AppCtxGlobals):
lib: beets.library.Library
g = LibraryCtx()
else:
from flask import g
# Utilities.
@ -232,7 +244,7 @@ def _get_unique_table_field_values(model, field, sort_field):
raise KeyError
with g.lib.transaction() as tx:
rows = tx.query(
f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'"
f"SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}"
)
return [row[0] for row in rows]

View file

@ -241,6 +241,11 @@ var AppView = Backbone.View.extend({
'pause': _.bind(this.audioPause, this),
'ended': _.bind(this.audioEnded, this)
});
if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("nexttrack", () => {
this.playNext();
});
}
},
showItems: function(items) {
this.shownItems = items;
@ -306,7 +311,9 @@ var AppView = Backbone.View.extend({
},
audioEnded: function() {
this.playingItem.entryView.setPlaying(false);
this.playNext();
},
playNext: function(){
// Try to play the next track.
var idx = this.shownItems.indexOf(this.playingItem);
if (idx == -1) {

View file

@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin):
"fields": [],
"keep_fields": [],
"update_database": False,
"omit_single_disc": False,
}
)
@ -123,9 +124,14 @@ class ZeroPlugin(BeetsPlugin):
"""
fields_set = False
if "disc" in tags and self.config["omit_single_disc"].get(bool):
if item.disctotal == 1:
fields_set = True
self._log.debug("disc: {.disc} -> None", item)
tags["disc"] = None
if not self.fields_to_progs:
self._log.warning("no fields, nothing to do")
return False
self._log.warning("no fields list to remove")
for field, progs in self.fields_to_progs.items():
if field in tags:

View file

@ -7,14 +7,115 @@ below!
Unreleased
----------
Beets now requires Python 3.10 or later since support for EOL Python 3.9 has
been dropped.
New features:
- :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle.
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
genres tag.
- :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and
album artist are the same in ftintitle.
- :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist
filepath into the command calling the player program.
- :doc:`plugins/lastgenre`: For tuning plugin settings ``-vvv`` can be passed
to receive extra verbose logging around last.fm results and how they are
resolved. The ``extended_debug`` config setting and ``--debug`` option
have been removed.
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
MusicBrainz pseudo-releases as recommendations during import.
- Added support for Python 3.13.
- :doc:`/plugins/convert`: ``force`` can be passed to override checks like
no_convert, never_convert_lossy_files, same format, and max_bitrate
- :doc:`plugins/titlecase`: Add the `titlecase` plugin to allow users to
resolve differences in metadata source styles.
Bug fixes:
- :doc:`plugins/inline`: Fix recursion error when an inline field definition
shadows a built-in item field (e.g., redefining ``track_no``). Inline
expressions now skip self-references during evaluation to avoid infinite
recursion. :bug:`6115`
- When hardlinking from a symlink (e.g. importing a symlink with hardlinking
enabled), dereference the symlink then hardlink, rather than creating a new
(potentially broken) symlink :bug:`5676`
- :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API
deprecation (HTTP 403 errors). When a 403 error is encountered from the
audio-features endpoint, the plugin logs a warning once and skips audio
features for all remaining tracks in the session, avoiding unnecessary API
calls and rate limit exhaustion.
- Running `beet --config <mypath> config -e` now edits `<mypath>` rather than
the default config path. :bug:`5652`
- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only
accepted a list of strings). :bug:`5962`
- Fix a bug introduced in release 2.4.0 where import from any valid
import-log-file always threw a "none of the paths are importable" error.
- :doc:`/plugins/web`: repair broken `/item/values/…` and `/albums/values/…`
endpoints. Previously, due to single-quotes (ie. string literal) in the SQL
query, the query eg. `GET /item/values/albumartist` would return the literal
"albumartist" instead of a list of unique album artists.
- Sanitize log messages by removing control characters preventing terminal
rendering issues.
For plugin developers:
- A new plugin event, ``album_matched``, is sent when an album that is being
imported has been matched to its metadata and the corresponding distance has
been calculated.
For packagers:
- The minimum supported Python version is now 3.10.
Other changes:
- The documentation chapter :doc:`dev/paths` has been moved to the "For
Developers" section and revised to reflect current best practices (pathlib
usage).
- Refactored the ``beets/ui/commands.py`` monolithic file (2000+ lines) into
multiple modules within the ``beets/ui/commands`` directory for better
maintainability.
- :doc:`plugins/bpd`: Raise ImportError instead of ValueError when GStreamer is
unavailable, enabling ``importorskip`` usage in pytest setup.
- Finally removed gmusic plugin and all related code/docs as the Google Play
Music service was shut down in 2020.
2.5.1 (October 14, 2025)
------------------------
New features:
- :doc:`plugins/zero`: Add new configuration option, ``omit_single_disc``, to
allow zeroing the disc number on write for single-disc albums. Defaults to
False.
Bug fixes:
- |BeetsPlugin|: load the last plugin class defined in the plugin namespace.
:bug:`6093`
For packagers:
- Fixed issue with legacy metadata plugins not copying properties from the base
class.
- Reverted the following: When installing ``beets`` via git or locally the
version string now reflects the current git branch and commit hash.
:bug:`6089`
Other changes:
- Removed outdated mailing list contact information from the documentation
:bug:`5462`.
- :doc:`guides/main`: Modernized the *Getting Started* guide with tabbed
sections and dropdown menus. Installation instructions have been streamlined,
and a new subpage now provides additional setup details.
- Documentation: introduced a new role ``conf`` for documenting configuration
options. This role provides consistent formatting and creates references
automatically. Applied it to :doc:`plugins/deezer`, :doc:`plugins/discogs`,
:doc:`plugins/musicbrainz` and :doc:`plugins/spotify` plugins documentation.
2.5.0 (October 11, 2025)
------------------------
@ -24,16 +125,18 @@ New features:
without storing or writing them.
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
converted files.
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
stripping discogs numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs`: New config option
:conf:`plugins.discogs:strip_disambiguation` to toggle stripping discogs
numeric disambiguation on artist and label fields.
- :doc:`plugins/discogs` Added support for featured artists. :bug:`6038`
- :doc:`plugins/discogs` New configuration option `featured_string` to change
the default string used to join featured artists. The default string is
`Feat.`.
- :doc:`plugins/discogs` New configuration option
:conf:`plugins.discogs:featured_string` to change the default string used to
join featured artists. The default string is `Feat.`.
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
:bug:`3354`
- :doc:`plugins/discogs` Support for name variations and config options to
specify where the variations are written. :bug:`3354`
- :doc:`plugins/web` Support for `nexttrack` keyboard press
Bug fixes:
@ -53,15 +156,14 @@ Bug fixes:
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
artists but not labels. :bug:`5366`
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
an import of another |BeetsPlugin| class. :bug:`6033`
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
regexps, allow for more cases, add some logging), add tests.
- Metadata source plugins: Fixed data source penalty calculation that was
incorrectly applied during import matching. The ``source_weight``
configuration option has been renamed to ``data_source_mismatch_penalty`` to
better reflect its purpose. :bug:`6066`
For packagers:
incorrectly applied during import matching. The
:conf:`plugins.index:source_weight` configuration option has been renamed to
:conf:`plugins.index:data_source_mismatch_penalty` to better reflect its
purpose. :bug:`6066`
Other changes:
@ -107,12 +209,13 @@ New features:
separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``,
but if you've customized your ``plugins`` list in your configuration, you'll
need to explicitly add ``musicbrainz`` to continue using this functionality.
Configuration option ``musicbrainz.enabled`` has thus been deprecated.
:bug:`2686` :bug:`4605`
Configuration option :conf:`plugins.musicbrainz:enabled` has thus been
deprecated. :bug:`2686` :bug:`4605`
- :doc:`plugins/web`: Show notifications when a track plays. This uses the Media
Session API to customize media notifications.
- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the
number of results returned by the Discogs metadata search queries.
- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit`
option to limit the number of results returned by the Discogs metadata search
queries.
- :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving
singletons by their Discogs ID. :bug:`4661`
- :doc:`plugins/replace`: Add new plugin.
@ -127,12 +230,13 @@ New features:
be played for it to be counted as played instead of skipped.
- :doc:`plugins/web`: Display artist and album as part of the search results.
- :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option
``search_limit`` to limit the number of results returned by search queries.
:conf:`plugins.index:search_limit` to limit the number of results returned by
search queries.
Bug fixes:
- :doc:`plugins/musicbrainz`: fix regression where user configured
``extra_tags`` have been read incorrectly. :bug:`5788`
:conf:`plugins.musicbrainz:extra_tags` have been read incorrectly. :bug:`5788`
- tests: Fix library tests failing on Windows when run from outside ``D:/``.
:bug:`5802`
- Fix an issue where calling ``Library.add`` would cause the ``database_change``
@ -164,9 +268,10 @@ Bug fixes:
For packagers:
- Optional ``extra_tags`` parameter has been removed from
``BeetsPlugin.candidates`` method signature since it is never passed in. If
you override this method in your plugin, feel free to remove this parameter.
- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed
from ``BeetsPlugin.candidates`` method signature since it is never passed in.
If you override this method in your plugin, feel free to remove this
parameter.
- Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every
python version.
@ -177,8 +282,8 @@ For plugin developers:
art sources might need to be adapted.
- We split the responsibilities of plugins into two base classes
1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any
plugin needs to inherit from this class.
1. |BeetsPlugin| is the base class for all plugins, any plugin needs to
inherit from this class.
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in
the beets repo are opted into this class where applicable. If you are
@ -380,6 +485,7 @@ New features:
``beet list -a title:something`` or ``beet list artpath:cover``. Consequently
album queries involving ``path`` field have been sped up, like ``beet list -a
path:/path/``.
- :doc:`plugins/importsource`: Added plugin
- :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which
allows keeping the "feat." part in the artist metadata while still changing
the title.
@ -522,8 +628,9 @@ New features:
:bug:`4348`
- Create the parental directories for database if they do not exist. :bug:`3808`
:bug:`4327`
- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows
disabling the MusicBrainz metadata source during the autotagging process
- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option
allows disabling the MusicBrainz metadata source during the autotagging
process
- :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101`
- Add the item fields ``bitrate_mode``, ``encoder_info`` and
``encoder_settings``.
@ -556,8 +663,8 @@ New features:
:bug:`4561` :bug:`4600`
- :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
extracted from those URL's and imported to the library. :bug:`4220`
enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's
will be extracted from those URL's and imported to the library. :bug:`4220`
- :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
with converted media files. :bug:`4373`
- Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809`
@ -911,8 +1018,9 @@ Other new things:
- ``beet remove`` now also allows interactive selection of items from the query,
similar to ``beet modify``.
- Enable HTTPS for MusicBrainz by default and add configuration option ``https``
for custom servers. See :ref:`musicbrainz-config` for more details.
- Enable HTTPS for MusicBrainz by default and add configuration option
:conf:`plugins.musicbrainz:https` for custom servers. See
:ref:`musicbrainz-config` for more details.
- :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the
right local path from MPD information.
- :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
@ -932,8 +1040,8 @@ Other new things:
server.
- :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
token- and password-based authentication based on the server version.
- A new :ref:`extra_tags` configuration option lets you use more metadata in
MusicBrainz queries to further narrow the search.
- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use
more metadata in MusicBrainz queries to further narrow the search.
- A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
- :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
@ -987,9 +1095,9 @@ Other new things:
(and now deprecated) separate ``host``, ``port``, and ``contextpath`` config
options. As a consequence, the plugin can now talk to Subsonic over HTTPS.
Thanks to :user:`jef`. :bug:`3449`
- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation
of work names and intra-work divisions into imported track titles. Thanks to
:user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option
enables incorporation of work names and intra-work divisions into imported
track titles. Thanks to :user:`cole-miller`. :bug:`3459`
- :doc:`/plugins/web`: The query API now interprets backslashes as path
separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567`
- ``beet import`` now handles tar archives with bzip2 or gzip compression.
@ -1003,9 +1111,9 @@ Other new things:
:user:`logan-arens`. :bug:`2947`
- There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins
to load.
- A new :ref:`genres` option fetches genre information from MusicBrainz. This
functionality depends on functionality that is currently unreleased in the
python-musicbrainzngs_ library: see PR `#266
- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from
MusicBrainz. This functionality depends on functionality that is currently
unreleased in the python-musicbrainzngs_ library: see PR `#266
<https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to
:user:`aereaux`.
- :doc:`/plugins/replaygain`: Analysis now happens in parallel using the
@ -1045,9 +1153,10 @@ Fixes:
:bug:`3867`
- :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be
redacted even when ``include_paths`` option is set. :bug:`3866`
- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that
sometimes caused the index to be discarded. Also, remove the extra semicolon
that was added when there is no index track.
- :doc:`/plugins/discogs`: Fixed a bug with the
:conf:`plugins.discogs:index_tracks` option that sometimes caused the index to
be discarded. Also, remove the extra semicolon that was added when there is no
index track.
- :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method
rather the ``GET`` method. Also includes better exception handling, response
parsing, and tests.
@ -1256,9 +1365,9 @@ There are some fixes in this release:
- Fix a regression in the last release that made the image resizer fail to
detect older versions of ImageMagick. :bug:`3269`
- :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more
- ``/plugins/gmusic``: The ``oauth_file`` config option now supports more
flexible path values, including ``~`` for the home directory. :bug:`3270`
- :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the
- ``/plugins/gmusic``: Fix a crash when using version 12.0.0 or later of the
``gmusicapi`` module. :bug:`3270`
- Fix an incompatibility with Python 3.8's AST changes. :bug:`3278`
@ -1309,7 +1418,7 @@ And many improvements to existing plugins:
singletons. :bug:`3220` :bug:`3219`
- :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues
with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944`
- :doc:`/plugins/gmusic`:
- ``/plugins/gmusic``:
- Add a new option to automatically upload to Google Play Music library on
track import. Thanks to :user:`shuaiscott`.
@ -1748,7 +1857,7 @@ Here are the new features:
- :ref:`Date queries <datequery>` can also be *relative*. You can say
``added:-1w..`` to match music added in the last week, for example. Thanks to
:user:`euri10`. :bug:`2598`
- A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music
- A new ``/plugins/gmusic`` lets you interact with your Google Play Music
library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586`
- :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from
classic ReplayGain data for formats that need it (namely, Ogg Opus). A new
@ -2663,9 +2772,9 @@ Major new features and bigger changes:
analysis tool. Thanks to :user:`jmwatte`. :bug:`1343`
- A new ``filesize`` field on items indicates the number of bytes in the file.
:bug:`1291`
- A new :ref:`search_limit` configuration option allows you to specify how many
search results you wish to see when looking up releases at MusicBrainz during
import. :bug:`1245`
- A new :conf:`plugins.index:search_limit` configuration option allows you to
specify how many search results you wish to see when looking up releases at
MusicBrainz during import. :bug:`1245`
- The importer now records the data source for a match in a new flexible
attribute ``data_source`` on items and albums. :bug:`1311`
- The colors used in the terminal interface are now configurable via the new
@ -5061,7 +5170,7 @@ BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin
list of plugin names) and ``pluginpath`` (a colon-separated list of
directories to search beyond ``sys.path``). Plugins are just Python modules
under the ``beetsplug`` namespace package containing subclasses of
``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or
|BeetsPlugin|. See `the beetsplug directory`_ for examples or
:doc:`/plugins/index` for instructions.
- As a consequence of adding album art, the database was significantly
refactored to keep track of some information at an album (rather than item)

View file

@ -6,6 +6,11 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
from pathlib import Path
# Add custom extensions directory to path
sys.path.insert(0, str(Path(__file__).parent / "extensions"))
project = "beets"
AUTHOR = "Adrian Sampson"
@ -14,7 +19,7 @@ copyright = "2016, Adrian Sampson"
master_doc = "index"
language = "en"
version = "2.5"
release = "2.5.0"
release = "2.5.1"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -23,13 +28,17 @@ extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.extlinks",
"sphinx.ext.viewcode",
"sphinx_design",
"sphinx_copybutton",
"conf",
]
autosummary_generate = True
exclude_patterns = ["_build"]
templates_path = ["_templates"]
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
pygments_style = "sphinx"
# External links to the bug tracker and other sites.
@ -79,6 +88,7 @@ man_pages = [
rst_epilog = """
.. |Album| replace:: :class:`~beets.library.models.Album`
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
.. |Item| replace:: :class:`~beets.library.models.Item`

View file

@ -18,6 +18,7 @@ configuration files, respectively.
plugins/index
library
paths
importer
cli
../api/index

64
docs/dev/paths.rst Normal file
View file

@ -0,0 +1,64 @@
Handling Paths
==============
``pathlib`` provides a clean, cross-platform API for working with filesystem
paths.
Use the ``.filepath`` property on ``Item`` and ``Album`` library objects to
access paths as ``pathlib.Path`` objects. This produces a readable, native
representation suitable for printing, logging, or further processing.
Normalize paths using ``Path(...).expanduser().resolve()``, which expands ``~``
and resolves symlinks.
Cross-platform differences—such as path separators, Unicode handling, and
long-path support (Windows) are automatically managed by ``pathlib``.
When storing paths in the database, however, convert them to bytes with
``bytestring_path()``. Paths in Beets are currently stored as bytes, although
there are plans to eventually store ``pathlib.Path`` objects directly. To access
media file paths in their stored form, use the ``.path`` property on ``Item``
and ``Album``.
Legacy utilities
----------------
Historically, Beets used custom utilities to ensure consistent behavior across
Linux, macOS, and Windows before ``pathlib`` became reliable:
- ``syspath()``: worked around Windows Unicode and long-path limitations by
converting to a system-safe string (adding the ``\\?\`` prefix where needed).
- ``normpath()``: normalized slashes and removed ``./`` or ``..`` parts but did
not expand ``~``.
- ``bytestring_path()``: converted paths to bytes for database storage (still
used for that purpose today).
- ``displayable_path()``: converted byte paths to Unicode for display or
logging.
These functions remain safe to use in legacy code, but new code should rely
solely on ``pathlib.Path``.
Examples
--------
Old style
.. code-block:: python
displayable_path(item.path)
normpath("~/Music/../Artist")
syspath(path)
New style
.. code-block:: python
item.filepath
Path("~/Music/../Artist").expanduser().resolve()
Path(path)
When storing paths in the database
.. code-block:: python
path_bytes = bytestring_path(Path("/some/path/to/file.mp3"))

View file

@ -95,9 +95,9 @@ starting points include:
Migration guidance
------------------
Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should
be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed
in **beets v3.0.0**.
Older metadata plugins that extend |BeetsPlugin| should be migrated to
:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets
v3.0.0**.
.. seealso::

View file

@ -178,6 +178,13 @@ registration process in this case:
:Parameters: ``info`` (|AlbumInfo|)
:Description: Like ``trackinfo_received`` but for album-level metadata.
``album_matched``
:Parameters: ``match`` (``AlbumMatch``)
:Description: Called after ``Item`` objects from a folder that's being
imported have been matched to an ``AlbumInfo`` and the corresponding
distance has been calculated. Missing and extra tracks, if any, are
included in the match.
``before_choose_candidate``
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
:Description: Called before prompting the user during interactive import.

View file

@ -40,8 +40,8 @@ or your plugin subpackage
anymore.
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For
instance, a minimal plugin without any functionality would look like this:
extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
plugin without any functionality would look like this:
.. code-block:: python
@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this:
class MyAwesomePlugin(BeetsPlugin):
pass
.. attention::
If your plugin is composed of intermediate |BeetsPlugin| subclasses, make
sure that your plugin is defined *last* in the namespace. We only load the
last subclass of |BeetsPlugin| we find in your plugin namespace.
To use your new plugin, you need to package [3]_ your plugin and install it into
your ``beets`` (virtual) environment. To enable your plugin, add it it to the
beets configuration

View file

@ -13,7 +13,7 @@ str.format-style string formatting. So you can write logging calls like this:
.. _pep 3101: https://www.python.org/dev/peps/pep-3101/
.. _standard python logging module: https://docs.python.org/2/library/logging.html
.. _standard python logging module: https://docs.python.org/3/library/logging.html
When beets is in verbose mode, plugin messages are prefixed with the plugin name
to make them easier to see.

View file

@ -13,7 +13,7 @@ shall expose to the user:
.. code-block:: python
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beets.util import PromptChoice
class ExamplePlugin(BeetsPlugin):

142
docs/extensions/conf.py Normal file
View file

@ -0,0 +1,142 @@
"""Sphinx extension for simple configuration value documentation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
from sphinx.util.nodes import make_refnode
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from docutils.nodes import Element
from docutils.parsers.rst.states import Inliner
from sphinx.addnodes import desc_signature, pending_xref
from sphinx.application import Sphinx
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
from sphinx.util.typing import ExtensionMetadata, OptionSpec
class Conf(ObjectDescription[str]):
"""Directive for documenting a single configuration value."""
option_spec: ClassVar[OptionSpec] = {
"default": directives.unchanged,
}
def handle_signature(self, sig: str, signode: desc_signature) -> str:
"""Process the directive signature (the config name)."""
signode += addnodes.desc_name(sig, sig)
# Add default value if provided
if "default" in self.options:
signode += nodes.Text(" ")
default_container = nodes.inline("", "")
default_container += nodes.Text("(default: ")
default_container += nodes.literal("", self.options["default"])
default_container += nodes.Text(")")
signode += default_container
return sig
def add_target_and_index(
self, name: str, sig: str, signode: desc_signature
) -> None:
"""Add cross-reference target and index entry."""
target = f"conf-{name}"
if target not in self.state.document.ids:
signode["ids"].append(target)
self.state.document.note_explicit_target(signode)
# A unique full name which includes the document name
index_name = f"{self.env.docname.replace('/', '.')}:{name}"
# Register with the conf domain
domain = self.env.get_domain("conf")
domain.data["objects"][index_name] = (self.env.docname, target)
# Add to index
self.indexnode["entries"].append(
("single", f"{name} (configuration value)", target, "", None)
)
class ConfDomain(Domain):
"""Domain for simple configuration values."""
name = "conf"
label = "Simple Configuration"
object_types = {"conf": ObjType("conf", "conf")}
directives = {"conf": Conf}
roles = {"conf": XRefRole()}
initial_data: dict[str, Any] = {"objects": {}}
def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
"""Return an iterable of object tuples for the inventory."""
for name, (docname, targetname) in self.data["objects"].items():
# Remove the document name prefix for display
display_name = name.split(":")[-1]
yield (name, display_name, "conf", docname, targetname, 1)
def resolve_xref(
self,
env: BuildEnvironment,
fromdocname: str,
builder: Builder,
typ: str,
target: str,
node: pending_xref,
contnode: Element,
) -> Element | None:
if entry := self.data["objects"].get(target):
docname, targetid = entry
return make_refnode(
builder, fromdocname, docname, targetid, contnode
)
return None
# sphinx.util.typing.RoleFunction
def conf_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
/,
options: dict[str, Any] | None = None,
content: Sequence[str] = (),
) -> tuple[list[nodes.Node], list[nodes.system_message]]:
"""Role for referencing configuration values."""
node = addnodes.pending_xref(
"",
refdomain="conf",
reftype="conf",
reftarget=text,
refwarn=True,
**(options or {}),
)
node += nodes.literal(text, text.split(":")[-1])
return [node], []
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_domain(ConfDomain)
# register a top-level directive so users can use ".. conf:: ..."
app.add_directive("conf", Conf)
# Register role with short name
app.add_role("conf", conf_role)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View file

@ -163,7 +163,7 @@ documentation </dev/index>` pages.
.. _bugs:
…report a bug in beets?
~~~~~~~~~~~~~~~~~~~~~~~
-----------------------
We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please
follow these guidelines when reporting an issue:
@ -171,7 +171,7 @@ follow these guidelines when reporting an issue:
- Most importantly: if beets is crashing, please `include the traceback
<https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them
in a pastebin (e.g., `Gist <https://gist.github.com/>`__ or `Hastebin
<https://hastebin.com/>`__), especially when communicating over IRC or email.
<https://hastebin.com/>`__), especially when communicating over IRC.
- Turn on beets' debug output (using the -v option: for example, ``beet -v
import ...``) and include that with your bug report. Look through this verbose
output for any red flags that might point to the problem.

View file

@ -9,5 +9,6 @@ guide.
:maxdepth: 1
main
installation
tagger
advanced

View file

@ -0,0 +1,179 @@
Installation
============
Beets requires `Python 3.10 or later`_. You can install it using package
managers, pipx_, pip_ or by using package managers.
.. _python 3.10 or later: https://python.org/download/
Using ``pipx`` or ``pip``
-------------------------
We recommend installing with pipx_ as it isolates beets and its dependencies
from your system Python and other Python packages. This helps avoid dependency
conflicts and keeps your system clean.
.. <!-- start-quick-install -->
.. tab-set::
.. tab-item:: pipx
.. code-block:: console
pipx install beets
.. tab-item:: pip
.. code-block:: console
pip install beets
.. tab-item:: pip (user install)
.. code-block:: console
pip install --user beets
.. <!-- end-quick-install -->
If you don't have pipx_ installed, you can follow the instructions on the `pipx
installation page`_ to get it set up.
.. _pip: https://pip.pypa.io/en/
.. _pipx: https://pipx.pypa.io/stable
.. _pipx installation page: https://pipx.pypa.io/stable/installation/
Using a Package Manager
-----------------------
Depending on your operating system, you may be able to install beets using a
package manager. Here are some common options:
.. attention::
Package manager installations may not provide the latest version of beets.
Release cycles for package managers vary, and they may not always have the
most recent version of beets. If you want the latest features and fixes,
consider using pipx_ or pip_ as described above.
Additionally, installing external beets plugins may be surprisingly
difficult when using a package manager.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
- Using **MacPorts**, run ``port install beets`` or ``port install beets-full``
to include many third-party plugins.
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
.. _aur: https://aur.archlinux.org/packages/beets-git/
.. _debian details: https://tracker.debian.org/pkg/beets
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
.. _openbsd: http://openports.se/audio/beets
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
Installation FAQ
----------------
MacOS Installation
~~~~~~~~~~~~~~~~~~
**Q: I'm getting permission errors on macOS. What should I do?**
Due to System Integrity Protection on macOS 10.11+, you may need to install for
your user only:
.. code-block:: console
pip install --user beets
You might need to also add ``~/Library/Python/3.x/bin`` to your ``$PATH``.
Windows Installation
~~~~~~~~~~~~~~~~~~~~
**Q: What's the process for installing on Windows?**
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
1. `Install Python`_ (check "Add Python to PATH" skip to 3)
2. Ensure Python is in your ``PATH`` (add if needed):
- Settings → System → About → Advanced system settings → Environment
Variables
- Edit "PATH" and add: `;C:\Python39;C:\Python39\Scripts`
- *Guide: [Adding Python to
PATH](https://realpython.com/add-python-to-path/)*
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet version`` in a new command prompt to verify the
installation.
**Bonus: Windows Context Menu Integration**
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
.. _install pip: https://pip.pypa.io/en/stable/installing/
.. _install python: https://python.org/download/
ARM Installation
~~~~~~~~~~~~~~~~
**Q: Can I run beets on a Raspberry Pi or other ARM device?**
Yes, but with some considerations: Beets on ARM devices is not recommended for
Linux novices. If you are comfortable with troubleshooting tools like ``pip``,
``make``, and binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``), you
will be fine. We have `notes for ARM`_ and an `older ARM reference`_. Beets is
generally developed on x86-64 based devices, and most plugins target that
platform as well.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993

View file

@ -1,322 +1,310 @@
Getting Started
===============
Welcome to beets_! This guide will help you begin using it to make your music
collection better.
Welcome to beets_! This guide will help get started with improving and
organizing your music collection.
.. _beets: https://beets.io/
Installing
----------
Quick Installation
------------------
You will need Python. Beets works on Python 3.8 or later.
Beets is distributed via PyPI_ and can be installed by most users with a single
command:
- **macOS** 11 (Big Sur) includes Python 3.8 out of the box. You can opt for a
more recent Python installing it via Homebrew_ (``brew install python3``).
There's also a MacPorts_ port. Run ``port install beets`` or ``port install
beets-full`` to include many third-party plugins.
- On **Debian or Ubuntu**, depending on the version, beets is available as an
official package (`Debian details`_, `Ubuntu details`_), so try typing:
``apt-get install beets``. But the version in the repositories might lag
behind, so make sure you read the right version of these docs. If you want the
latest version, you can get everything you need to install with pip as
described below by running: ``apt-get install python-dev python-pip``
- On **Arch Linux**, `beets is in [extra] <arch extra_>`_, so just run ``pacman
-S beets``. (There's also a bleeding-edge `dev package <aur_>`_ in the AUR,
which will probably set your computer on fire.)
- On **Alpine Linux**, `beets is in the community repository <alpine package_>`_
and can be installed with ``apk add beets``.
- On **Void Linux**, `beets is in the official repository <void package_>`_ and
can be installed with ``xbps-install -S beets``.
- For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
``emerge beets`` to install. There are several USE flags available for
optional plugin dependencies.
- On **FreeBSD**, there's a `beets port <freebsd_>`_ at ``audio/beets``.
- On **OpenBSD**, there's a `beets port <openbsd_>`_ can be installed with
``pkg_add beets``.
- For **Slackware**, there's a SlackBuild_ available.
- On **Fedora** 22 or later, there's a `DNF package`_ you can install with
``sudo dnf install beets beets-plugins beets-doc``.
- On **Solus**, run ``eopkg install beets``.
- On **NixOS**, there's a `package <nixos_>`_ you can install with ``nix-env -i
beets``.
.. include:: installation.rst
:start-after: <!-- start-quick-install -->
:end-before: <!-- end-quick-install -->
.. _alpine package: https://pkgs.alpinelinux.org/package/edge/community/x86_64/beets
.. admonition:: Need more installation options?
.. _arch extra: https://archlinux.org/packages/extra/any/beets/
Having trouble with the commands above? Looking for package manager
instructions? See the :doc:`complete installation guide
</guides/installation>` for:
.. _aur: https://aur.archlinux.org/packages/beets-git/
- Operating system specific instructions
- Package manager options
- Troubleshooting help
.. _debian details: https://tracker.debian.org/pkg/beets
.. _pypi: https://pypi.org/project/beets/
.. _dnf package: https://packages.fedoraproject.org/pkgs/beets/
Basic Configuration
-------------------
.. _freebsd: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
Before using beets, you'll need a configuration file. This YAML file tells beets
where to store your music and how to organize it.
.. _macports: https://www.macports.org
While beets is highly configurable, you only need a few basic settings to get
started.
.. _nixos: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
1. **Open the config file:**
.. code-block:: console
.. _openbsd: http://openports.se/audio/beets
beet config -e
.. _slackbuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
This creates the file (if needed) and opens it in your default editor.
You can also find its location with ``beet config -p``.
2. **Add required settings:**
In the config file, set the ``directory`` option to the path where you
want beets to store your music files. Set the ``library`` option to the
path where you want beets to store its database file.
.. _ubuntu details: https://launchpad.net/ubuntu/+source/beets
.. code-block:: yaml
.. _void package: https://github.com/void-linux/void-packages/tree/master/srcpkgs/beets
directory: ~/music
library: ~/data/musiclibrary.db
3. **Choose your import style** (pick one):
Beets offers flexible import strategies to match your workflow. Choose
one of the following approaches and put one of the following in your
config file:
If you have pip_, just say ``pip install beets`` (or ``pip install --user
beets`` if you run into permissions problems).
.. tab-set::
To install without pip, download beets from `its PyPI page`_ and run ``python
setup.py install`` in the directory therein.
.. tab-item:: Copy Files (Default)
.. _its pypi page: https://pypi.org/project/beets/#files
This is the default configuration and assumes you want to start a new organized music folder (inside ``directory`` above). During import we will *copy* cleaned-up music into that empty folder.
.. _pip: https://pip.pypa.io
.. code-block:: yaml
The best way to upgrade beets to a new version is by running ``pip install -U
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
new versions.
import:
copy: yes # Copy files to new location
.. _@b33ts: https://twitter.com/b33ts
Installing by Hand on macOS 10.11 and Higher
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. tab-item:: Move Files
Starting with version 10.11 (El Capitan), macOS has a new security feature
called `System Integrity Protection`_ (SIP) that prevents you from modifying
some parts of the system. This means that some ``pip`` commands may fail with a
permissions error. (You probably *won't* run into this if you've installed
Python yourself with Homebrew_ or otherwise. You can also try MacPorts_.)
Start with a new empty directory, but *move* new music in instead of copying it (saving disk space).
If this happens, you can install beets for the current user only by typing ``pip
install --user beets``. If you do that, you might want to add
``~/Library/Python/3.6/bin`` to your ``$PATH``.
.. code-block:: yaml
.. _homebrew: https://brew.sh
import:
move: yes # Move files to new location
.. _system integrity protection: https://support.apple.com/en-us/HT204899
.. tab-item:: Use Existing Structure
Installing on Windows
~~~~~~~~~~~~~~~~~~~~~
Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Make sure to point ``directory`` at the place where your music is currently stored.
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
.. code-block:: yaml
1. If you don't have it, `install Python`_ (you want at least Python 3.8). The
installer should give you the option to "add Python to PATH." Check this box.
If you do that, you can skip the next step.
2. If you haven't done so already, set your ``PATH`` environment variable to
include Python and its scripts. To do so, open the "Settings" application,
then access the "System" screen, then access the "About" tab, and then hit
"Advanced system settings" located on the right side of the screen. This
should open the "System Properties" screen, then select the "Advanced" tab,
then hit the "Environmental Variables..." button, and then look for the PATH
variable in the table. Add the following to the end of the variable's value:
``;C:\Python38;C:\Python38\Scripts``. You may need to adjust these paths to
point to your Python installation.
3. Now install beets by running: ``pip install beets``
4. You're all set! Type ``beet`` at the command prompt to make sure everything's
in order.
import:
copy: no # Use files in place
Windows users may also want to install a context menu item for importing files
into beets. Download the beets.reg_ file and open it in a text file to make sure
the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and choose
"Import with beets".
.. tab-item:: Read-Only Mode
Because I don't use Windows myself, I may have missed something. If you have
trouble or you have more detail to contribute here, please direct it to `the
mailing list`_.
Keep everything exactly as-is; only track metadata in database. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.)
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
.. code-block:: yaml
.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
import:
copy: no # Use files in place
write: no # Don't modify tags
4. **Add customization via plugins (optional):**
Beets comes with many plugins that extend its functionality. You can
enable plugins by adding a `plugins` section to your config file.
.. _install pip: https://pip.pypa.io/en/stable/installing/
We recommend adding at least one :ref:`Autotagger Plugin
<autotagger_extensions>` to help with fetching metadata during import.
For getting started, :doc:`MusicBrainz </plugins/musicbrainz>` is a good
choice.
.. _install python: https://python.org/download/
.. code-block:: yaml
Installing on ARM (Raspberry Pi and similar)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
plugins:
- musicbrainz # Example plugin for fetching metadata
- ... other plugins you want ...
Beets on ARM devices is not recommended for Linux novices. If you are
comfortable with light troubleshooting in tools like ``pip``, ``make``, and
beets' command-line binary dependencies (e.g. ``ffmpeg`` and ``ImageMagick``),
you will probably be okay on ARM devices like the Raspberry Pi. We have `notes
for ARM`_ and an `older ARM reference`_. Beets is generally developed on x86-64
based devices, and most plugins target that platform as well.
You can find a list of available plugins in the :doc:`plugins index
</plugins/index>`.
.. _notes for arm: https://github.com/beetbox/beets/discussions/4910
.. _yaml: https://yaml.org/
.. _older arm reference: https://discourse.beets.io/t/diary-of-beets-on-arm-odroid-hc4-armbian/1993
To validate that you've set up your configuration and it is valid YAML, you can
type ``beet version`` to see a list of enabled plugins or ``beet config`` to get
a complete listing of your current configuration.
Configuring
-----------
.. dropdown:: Minimal configuration
You'll want to set a few basic options before you start using beets. The
:doc:`configuration </reference/config>` is stored in a text file. You can show
its location by running ``beet config -p``, though it may not exist yet. Run
``beet config -e`` to edit the configuration in your favorite text editor. The
file will start out empty, but here's good place to start:
Here's a sample configuration file that includes the settings mentioned above:
::
.. code-block:: yaml
directory: ~/music
library: ~/data/musiclibrary.db
Change that first path to a directory where you'd like to keep your music. Then,
for ``library``, choose a good place to keep a database file that keeps an index
of your music. (The config's format is YAML_. You'll want to configure your text
editor to use spaces, not real tabs, for indentation. Also, ``~`` means your
home directory in these paths, even on Windows.)
The default configuration assumes you want to start a new organized music folder
(that ``directory`` above) and that you'll *copy* cleaned-up music into that
empty folder using beets' ``import`` command (see below). But you can configure
beets to behave many other ways:
- Start with a new empty directory, but *move* new music in instead of copying
it (saving disk space). Put this in your config file:
::
import:
move: yes
move: yes # Move files to new location
# copy: no # Use files in place
# write: no # Don't modify tags
- Keep your current directory structure; importing should never move or copy
files but instead just correct the tags on music. Put the line ``copy: no``
under the ``import:`` heading in your config file to disable any copying or
renaming. Make sure to point ``directory`` at the place where your music is
currently stored.
- Keep your current directory structure and *do not* correct files' tags: leave
files completely unmodified on your disk. (Corrected tags will still be stored
in beets' database, and you can use them to do renaming or tag changes later.)
Put this in your config file:
plugins:
- musicbrainz # Example plugin for fetching metadata
# - ... other plugins you want ...
::
You can copy and paste this into your config file and modify it as needed.
import:
copy: no
write: no
.. admonition:: Ready for more?
to disable renaming and tag-writing.
For a complete reference of all configuration options, see the
:doc:`configuration reference </reference/config>`.
There are other configuration options you can set here, including the directory
and file naming scheme. See :doc:`/reference/config` for a full reference.
Importing Your Music
--------------------
.. _yaml: https://yaml.org/
Now you're ready to import your music into beets!
To check that you've set up your configuration how you want it, you can type
``beet version`` to see a list of enabled plugins or ``beet config`` to get a
complete listing of your current configuration.
.. important::
Importing Your Library
----------------------
Importing can modify and move your music files. **Make sure you have a
recent backup** before proceeding.
The next step is to import your music files into the beets library database.
Because this can involve modifying files and moving them around, data loss is
always a possibility, so now would be a good time to make sure you have a recent
backup of all your music. We'll wait.
Choose Your Import Method
~~~~~~~~~~~~~~~~~~~~~~~~~
There are two good ways to bring your existing library into beets. You can
either: (a) quickly bring all your files with all their current metadata into
beets' database, or (b) use beets' highly-refined autotagger to find canonical
metadata for every album you import. Option (a) is really fast, but option (b)
makes sure all your songs' tags are exactly right from the get-go. The point
about speed bears repeating: using the autotagger on a large library can take a
There are two good ways to bring your *existing* library into beets database.
.. tab-set::
.. tab-item:: Autotag (Recommended)
This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go.
.. code-block:: console
beet import /a/chunk/of/my/library
.. warning::
The point about speed bears repeating: using the autotagger on a large library can take a
very long time, and it's an interactive process. So set aside a good chunk of
time if you're going to go that route. For more on the interactive tagging
time if you're going to go that route.
We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging
process, see :doc:`tagger`.
If you've got time and want to tag all your music right once and for all, do
this:
::
.. tab-item:: Quick Import
$ beet import /path/to/my/music
This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags.
(Note that by default, this command will *copy music into the directory you
specified above*. If you want to use your current directory structure, set the
``import.copy`` config option.) To take the fast, un-autotagged path, just say:
To use this method, run:
::
.. code-block:: console
$ beet import -A /my/huge/mp3/library
beet import --noautotag /my/huge/mp3/library
Note that you just need to add ``-A`` for "don't autotag".
The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata.
Adding More Music
-----------------
.. admonition:: More Import Options
If you've ripped or... otherwise obtained some new music, you can add it with
the ``beet import`` command, the same way you imported your library. Like so:
The ``beet import`` command has many options to customize its behavior. For
a full list, type ``beet help import`` or see the :ref:`import command
reference <import-cmd>`.
::
Adding More Music Later
~~~~~~~~~~~~~~~~~~~~~~~
$ beet import ~/some_great_album
When you acquire new music, use the same ``beet import`` command to add it to
your library:
This will attempt to autotag the new album (interactively) and add it to your
library. There are, of course, more options for this command---just type ``beet
help import`` to see what's available.
.. code-block:: console
beet import ~/new_totally_not_ripped_album
This will apply the same autotagging process to your new additions. For
alternative import behaviors, consult the options mentioned above.
Seeing Your Music
-----------------
If you want to query your music library, the ``beet list`` (shortened to ``beet
ls``) command is for you. You give it a :doc:`query string </reference/query>`,
which is formatted something like a Google search, and it gives you a list of
songs. Thus:
Once you've imported music into beets, you'll want to explore and query your
library. Beets provides several commands for searching, browsing, and getting
statistics about your collection.
::
Basic Searching
~~~~~~~~~~~~~~~
The ``beet list`` command (shortened to ``beet ls``) lets you search your music
library using :doc:`query string </reference/query>` similar to web searches:
.. code-block:: console
$ beet ls the magnetic fields
The Magnetic Fields - Distortion - Three-Way
The Magnetic Fields - Distortion - California Girls
The Magnetic Fields - Dist
The Magnetic Fields - Distortion - Old Fools
.. code-block:: console
$ beet ls hissing gronlandic
of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit
.. code-block:: console
$ beet ls bird
The Knife - The Knife - Bird
The Mae Shi - Terrorbird - Revelation Six
By default, search terms match against :ref:`common attributes <keywordquery>`
of songs, and multiple terms are combined with AND logic (a track must match
*all* criteria).
Searching Specific Fields
~~~~~~~~~~~~~~~~~~~~~~~~~
To narrow a search term to a particular metadata field, prefix the term with the
field name followed by a colon. For example, ``album:bird`` searches for "bird"
only in the "album" field of your songs. For more details, see
:doc:`/reference/query/`.
.. code-block:: console
$ beet ls album:bird
The Mae Shi - Terrorbird - Revelation Six
By default, a search term will match any of a handful of :ref:`common attributes
<keywordquery>` of songs. (They're also implicitly joined by ANDs: a track must
match *all* criteria in order to match the query.) To narrow a search term to a
particular metadata field, just put the field before the term, separated by a :
character. So ``album:bird`` only looks for ``bird`` in the "album" field of
your songs. (Need to know more? :doc:`/reference/query/` will answer all your
questions.)
This searches only the ``album`` field for the term ``bird``.
Searching for Albums
~~~~~~~~~~~~~~~~~~~~
The ``beet list`` command also has an ``-a`` option, which searches for albums
instead of songs:
::
.. code-block:: console
$ beet ls -a forever
Bon Iver - For Emma, Forever Ago
Freezepop - Freezepop Forever
Custom Output Formatting
~~~~~~~~~~~~~~~~~~~~~~~~
There's also an ``-f`` option (for *format*) that lets you specify what gets
displayed in the results of a search:
::
.. code-block:: console
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
In the format option, field references like ``$format`` and ``$year`` are filled
in with data from each result. You can see a full list of available fields by
running ``beet fields``.
In the format string, field references like ``$format``, ``$year``, ``$album``,
etc., are replaced with data from each result.
Beets also has a ``stats`` command, just in case you want to see how much music
you have:
.. dropdown:: Available fields for formatting
::
To see all available fields you can use in custom formats, run:
.. code-block:: console
beet fields
This will display a comprehensive list of metadata fields available for your music.
Library Statistics
~~~~~~~~~~~~~~~~~~
Beets can also show you statistics about your music collection:
.. code-block:: console
$ beet stats
Tracks: 13019
@ -325,31 +313,107 @@ you have:
Artists: 548
Albums: 1094
.. admonition:: Ready for more advanced queries?
The ``beet list`` command has many additional options for sorting, limiting
results, and more complex queries. For a complete reference, run:
.. code-block:: console
beet help list
Or see the :ref:`list command reference <list-cmd>`.
Keep Playing
------------
This is only the beginning of your long and prosperous journey with beets. To
keep learning, take a look at :doc:`advanced` for a sampling of what else is
possible. You'll also want to glance over the :doc:`/reference/cli` page for a
more detailed description of all of beets' functionality. (Like deleting music!
That's important.)
Congratulations! You've now mastered the basics of beets. But this is only the
beginning, beets has many more powerful features to explore.
Also, check out :doc:`beets' plugins </plugins/index>`. The real power of beets
is in its extensibility---with plugins, beets can do almost anything for your
music collection.
Continue Your Learning Journey
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can always get help using the ``beet help`` command. The plain ``beet help``
command lists all the available commands; then, for example, ``beet help
import`` gives more specific help about the ``import`` command.
*I was there to push people beyond what's expected of them.*
If you need more of a walkthrough, you can read an illustrated one `on the beets
blog <https://beets.io/blog/walkthrough.html>`_.
.. grid:: 2
:gutter: 3
Please let us know what you think of beets via `the discussion board`_ or
Mastodon_.
.. grid-item-card:: :octicon:`zap` Advanced Techniques
:link: advanced
:link-type: doc
.. _mastodon: https://fosstodon.org/@beets
Explore sophisticated beets workflows including:
.. _the discussion board: https://github.com/beetbox/beets/discussions
- Advanced tagging strategies
- Complex import scenarios
- Custom metadata management
- Workflow automation
.. _the mailing list: https://groups.google.com/group/beets-users
.. grid-item-card:: :octicon:`terminal` Command Reference
:link: /reference/cli
:link-type: doc
Comprehensive guide to all beets commands:
- Complete command syntax
- All available options
- Usage examples
- **Important operations like deleting music**
.. grid-item-card:: :octicon:`plug` Plugin Ecosystem
:link: /plugins/index
:link-type: doc
Discover beets' true power through plugins:
- Metadata fetching from multiple sources
- Audio analysis and processing
- Streaming service integration
- Custom export formats
.. grid-item-card:: :octicon:`question` Illustrated Walkthrough
:link: https://beets.io/blog/walkthrough.html
:link-type: url
Visual, step-by-step guide covering:
- Real-world import examples
- Screenshots of interactive tagging
- Common workflow patterns
- Troubleshooting tips
.. admonition:: Need Help?
Remember you can always use ``beet help`` to see all available commands, or
``beet help [command]`` for detailed help on specific commands.
Join the Community
~~~~~~~~~~~~~~~~~~
We'd love to hear about your experience with beets!
.. grid:: 2
:gutter: 2
.. grid-item-card:: :octicon:`comment-discussion` Discussion Board
:link: https://github.com/beetbox/beets/discussions
:link-type: url
- Ask questions
- Share tips and tricks
- Discuss feature ideas
- Get help from other users
.. grid-item-card:: :octicon:`git-pull-request` Developer Resources
:link: /dev/index
:link-type: doc
- Contribute code
- Report issues
- Review pull requests
- Join development discussions
.. admonition:: Found a Bug?
If you encounter any issues, please report them on our `GitHub Issues page
<https://github.com/beetbox/beets/issues>`_.

View file

@ -311,5 +311,3 @@ If we haven't made the process clear, please post on `the discussion board`_ and
we'll try to improve this guide.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users

View file

@ -13,9 +13,8 @@ Then you can get a more detailed look at beets' features in the
be interested in exploring the :doc:`plugins </plugins/index>`.
If you still need help, you can drop by the ``#beets`` IRC channel on
Libera.Chat, drop by `the discussion board`_, send email to `the mailing list`_,
or `file a bug`_ in the issue tracker. Please let us know where you think this
documentation can be improved.
Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue
tracker. Please let us know where you think this documentation can be improved.
.. _beets: https://beets.io/
@ -23,8 +22,6 @@ documentation can be improved.
.. _the discussion board: https://github.com/beetbox/beets/discussions/
.. _the mailing list: https://groups.google.com/group/beets-users
Contents
--------

View file

@ -51,6 +51,11 @@ instead, passing ``-H`` (``--hardlink``) creates hard links. Note that album art
embedding is disabled for files that are linked. Refer to the ``link`` and
``hardlink`` options below.
The ``-F`` (or ``--force``) option forces transcoding even when safety options
such as ``no_convert``, ``never_convert_lossy_files``, or ``max_bitrate`` would
normally cause a file to be copied or skipped instead. This can be combined with
``--format`` to explicitly transcode lossy inputs to a chosen target format.
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
playlist file in the destination folder given by the ``-d`` (``--dest``) option
or the ``dest`` configuration. The path to the playlist file can either be
@ -104,15 +109,21 @@ The available options are:
with high bitrates, even if they are already in the same format as the output.
Note that this does not guarantee that all converted files will have a lower
bitrate---that depends on the encoder and its configuration. Default: none.
This option will be overridden by the ``--force`` flag
- **no_convert**: Does not transcode items matching the query string provided
(see :doc:`/reference/query`). For example, to not convert AAC or WMA formats,
you can use ``format:AAC, format:WMA`` or ``path::\.(m4a|wma)$``. If you only
want to transcode WMA format, you can use a negative query, e.g.,
``^path::\.(wma)$``, to not convert any other format except WMA.
``^path::\.(wma)$``, to not convert any other format except WMA. This option
will be overridden by the ``--force`` flag
- **never_convert_lossy_files**: Cross-conversions between lossy codecs---such
as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality
even further. If set to ``yes``, lossy files are always copied. Default:
``no``.
``no``. When ``never_convert_lossy_files`` is enabled, lossy source files (for
example MP3 or Ogg Vorbis) are normally not transcoded and are instead copied
or linked as-is. To explicitly transcode lossy files in spite of this, use the
``--force`` option with the ``convert`` command (optionally together with
``--format`` to choose a target format)
- **paths**: The directory structure and naming scheme for the converted files.
Uses the same format as the top-level ``paths`` section (see
:ref:`path-format-config`). Default: Reuse your top-level path format

View file

@ -35,15 +35,23 @@ Default
.. code-block:: yaml
deezer:
search_query_ascii: no
data_source_mismatch_penalty: 0.5
search_limit: 5
search_query_ascii: no
- **search_query_ascii**: If set to ``yes``, the search query will be converted
to ASCII before being sent to Deezer. Converting searches to ASCII can enhance
search results in some cases, but in general, it is not recommended. For
instance ``artist:deadmau5 album:4×4`` will be converted to ``artist:deadmau5
album:4x4`` (notice ``×!=x``). Default: ``no``.
.. conf:: search_query_ascii
:default: no
If enabled, the search query will be converted to ASCII before being sent to
Deezer. Converting searches to ASCII can enhance search results in some cases,
but in general, it is not recommended. For instance, ``artist:deadmau5
album:4×4`` will be converted to ``artist:deadmau5 album:4x4`` (notice
``×!=x``).
.. include:: ./shared_metadata_source_config.rst
Commands
--------
The ``deezer`` plugin provides an additional command ``deezerupdate`` to update
the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a

View file

@ -71,21 +71,29 @@ Default
.. code-block:: yaml
discogs:
data_source_mismatch_penalty: 0.5
search_limit: 5
apikey: REDACTED
apisecret: REDACTED
tokenfile: discogs_token.json
user_token: REDACTED
user_token:
index_tracks: no
append_style_genre: no
separator: ', '
strip_disambiguation: yes
featured_string: Feat.
anv:
artist_credit: yes
artist: no
album_artist: no
data_source_mismatch_penalty: 0.5
search_limit: 5
- **index_tracks**: Index tracks (see the `Discogs guidelines`_) along with
headers, mark divisions between distinct works on the same release or within
works. When enabled, beets will incorporate the names of the divisions
containing each track into the imported track's title. Default: ``no``.
.. conf:: index_tracks
:default: no
Index tracks (see the `Discogs guidelines`_) along with headers, mark divisions
between distinct works on the same release or within works. When enabled,
beets will incorporate the names of the divisions containing each track into the
imported track's title.
For example, importing `divisions album`_ would result in track names like:
@ -105,20 +113,36 @@ Default
This option is useful when importing classical music.
- **append_style_genre**: Appends the Discogs style (if found) to the genre tag.
This can be useful if you want more granular genres to categorize your music.
For example, a release in Discogs might have a genre of "Electronic" and a
style of "Techno": enabling this setting would set the genre to be
"Electronic, Techno" (assuming default separator of ``", "``) instead of just
"Electronic". Default: ``False``
- **separator**: How to join multiple genre and style values from Discogs into a
string. Default: ``", "``
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
artists and labels with the same name. If you'd like to use the discogs
disambiguation in your tags, you can disable it. Default: ``True``
- **featured_string**: Configure the string used for noting featured artists.
Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.``
- **anv**: These configuration option are dedicated to handling Artist Name
.. conf:: append_style_genre
:default: no
Appends the Discogs style (if found) to the genre tag. This can be useful if
you want more granular genres to categorize your music. For example,
a release in Discogs might have a genre of "Electronic" and a style of
"Techno": enabling this setting would set the genre to be "Electronic,
Techno" (assuming default separator of ``", "``) instead of just
"Electronic".
.. conf:: separator
:default: ", "
How to join multiple genre and style values from Discogs into a string.
.. conf:: strip_disambiguation
:default: yes
Discogs uses strings like ``"(4)"`` to mark distinct artists and labels with
the same name. If you'd like to use the Discogs disambiguation in your tags,
you can disable this option.
.. conf:: featured_string
:default: Feat.
Configure the string used for noting featured artists. Useful if you prefer ``Featuring`` or ``ft.``.
.. conf:: anv
This configuration option is dedicated to handling Artist Name
Variations (ANVs). Sometimes a release credits artists differently compared to
the majority of their work. For example, "Basement Jaxx" may be credited as
"Tha Jaxx" or "The Basement Jaxx". You can select any combination of these
@ -129,9 +153,11 @@ Default
discogs:
anv:
artist_credit: True
artist: False
album_artist: False
artist_credit: yes
artist: no
album_artist: no
.. include:: ./shared_metadata_source_config.rst
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings

View file

@ -70,7 +70,7 @@ These options match the options from the `Python csv module`_.
.. _python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params
.. _python json module: https://docs.python.org/2/library/json.html#basic-usage
.. _python json module: https://docs.python.org/3/library/json.html#basic-usage
The default options look like this:

View file

@ -28,6 +28,18 @@ file. The available options are:
- **keep_in_artist**: Keep the featuring X part in the artist field. This can be
useful if you still want to be able to search for features in the artist
field. Default: ``no``.
- **preserve_album_artist**: If the artist and the album artist are the same,
skip the ftintitle processing. Default: ``yes``.
- **custom_words**: List of additional words that will be treated as a marker
for artist features. Default: ``[]``.
Path Template Values
--------------------
This plugin provides the ``album_artist_no_feat`` :ref:`template value
<templ_plugins>` that you can use in your :ref:`path-format-config` in
``paths.default``. Any ``custom_words`` in the configuration are taken into
account.
Running Manually
----------------

View file

@ -1,5 +0,0 @@
Gmusic Plugin
=============
The ``gmusic`` plugin interfaced beets to Google Play Music. It has been removed
after the shutdown of this service.

View file

@ -0,0 +1,80 @@
ImportSource Plugin
===================
The ``importsource`` plugin adds a ``source_path`` field to every item imported
to the library which stores the original media files' paths. Using this plugin
makes most sense when the general importing workflow is using ``beet import
--copy``. Additionally the plugin interactively suggests deletion of original
source files whenever items are removed from the Beets library.
To enable it, add ``importsource`` to the list of plugins in your configuration
(see :ref:`using-plugins`).
Tracking Source Paths
---------------------
The primary use case for the plugin is tracking the original location of
imported files using the ``source_path`` field. Consider this scenario: you've
imported all directories in your current working directory using:
.. code-block:: bash
beet import --flat --copy */
Later, for instance if the import didn't complete successfully, you'll need to
rerun the import but don't want Beets to re-process the already successfully
imported directories. You can view which files were successfully imported using:
.. code-block:: bash
beet ls source_path:$PWD --format='$source_path'
To extract just the directory names, pipe the output to standard UNIX utilities:
.. code-block:: bash
beet ls source_path:$PWD --format='$source_path' | awk -F / '{print $(NF-1)}' | sort -u
This might help to find out what's left to be imported.
Removal Suggestion
------------------
Another feature of the plugin is suggesting removal of original source files
when items are deleted from your library. Consider this scenario: you imported
an album using:
.. code-block:: bash
beet import --copy --flat ~/Desktop/interesting-album-to-check/
After listening to that album and deciding it wasn't good, you want to delete it
from your library as well as from your ``~/Desktop``, so you run:
.. code-block:: bash
beet remove --delete source_path:$HOME/Desktop/interesting-album-to-check
After approving the deletion, the plugin will prompt:
.. code-block:: text
The item:
<music-library>/Interesting Album/01 Interesting Song.flac
is originated from:
<HOME>/Desktop/interesting-album-to-check/01-interesting-song.flac
What would you like to do?
Delete the item's source, Recursively delete the source's directory,
do Nothing,
do nothing and Stop suggesting to delete items from this album?
Configuration
-------------
To configure the plugin, make an ``importsource:`` section in your configuration
file. There is one option available:
- **suggest_removal**: By default ``importsource`` suggests to remove the
original directories / files from which the items were imported whenever
library items (and files) are removed. To disable these prompts set this
option to ``no``. Default: ``yes``.

Some files were not shown because too many files have changed in this diff Show more