mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +01:00
Compare commits
216 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd77b9895 | ||
|
|
53931279a3 | ||
|
|
fdaebc653a | ||
|
|
ca7e959f5b | ||
|
|
67e668d81f | ||
|
|
6abb901b6b | ||
|
|
05430f312c | ||
|
|
dd72704d3d | ||
|
|
b643fc4ce5 | ||
|
|
3bb068a675 | ||
|
|
9f7cb8dbe4 | ||
|
|
5a3ecf6842 | ||
|
|
39288637b9 | ||
|
|
c79cad4ed1 | ||
|
|
95b3364361 | ||
|
|
26fde1ebf0 | ||
|
|
cb0d15ff84 | ||
|
|
5cc7dcfce7 | ||
|
|
b4f0dbf53b | ||
|
|
cd8e466a46 | ||
|
|
51164024c0 | ||
|
|
c59134bdb6 | ||
|
|
e827d43213 | ||
|
|
eb11537328 | ||
|
|
13f95dcf3a | ||
|
|
b902352139 | ||
|
|
4a17901c1d | ||
|
|
d446e10fb0 | ||
|
|
ba18ee2f14 | ||
|
|
be0b71043c | ||
|
|
23a19e9409 | ||
|
|
2eff2d25f5 | ||
|
|
9c37f94171 | ||
|
|
c0ca045c20 | ||
|
|
ba45fedde5 | ||
|
|
aced802c56 | ||
|
|
f79c125d15 | ||
|
|
aa2dc9005f | ||
|
|
88ca0ce1fb | ||
|
|
189fedb008 | ||
|
|
666c412b0e | ||
|
|
07445fdd07 | ||
|
|
97bc0b3b8c | ||
|
|
2ef77852b7 | ||
|
|
e326aafac0 | ||
|
|
672bf0bf41 | ||
|
|
d7636fb0c3 | ||
|
|
9e7d5debdc | ||
|
|
f3da80e512 | ||
|
|
29a5b06f67 | ||
|
|
3a72d85c5e | ||
|
|
bef249e616 | ||
|
|
881549e83c | ||
|
|
ffa70acad9 | ||
|
|
d486885af3 | ||
|
|
dc33932871 | ||
|
|
a7830bebae | ||
|
|
d64efbb6c1 | ||
|
|
b405d2fded | ||
|
|
29b9958626 | ||
|
|
81f10729e1 | ||
|
|
7cca07d2c3 | ||
|
|
26a8e164d5 | ||
|
|
f77c03ed90 | ||
|
|
60ad6dc503 | ||
|
|
0e74605efd | ||
|
|
a7becf8490 | ||
|
|
e9afe069bc | ||
|
|
86a74970f9 | ||
|
|
61a4c737ee | ||
|
|
f495a9e18d | ||
|
|
b3b7dc3316 | ||
|
|
25ae330044 | ||
|
|
a59e41a883 | ||
|
|
59c93e7013 | ||
|
|
beda6fc71b | ||
|
|
c087851770 | ||
|
|
040b2dd940 | ||
|
|
cb758988ed | ||
|
|
defc602310 | ||
|
|
160297b086 | ||
|
|
229651dcad | ||
|
|
a42cabb477 | ||
|
|
ab5705f444 | ||
|
|
79f691832c | ||
|
|
0d90649029 | ||
|
|
f3934dc58b | ||
|
|
ac0b221802 | ||
|
|
017930dd99 | ||
|
|
584329e7f0 | ||
|
|
7724c661a4 | ||
|
|
447511b4c8 | ||
|
|
8305821488 | ||
|
|
4302ca97eb | ||
|
|
e6c70f06c1 | ||
|
|
0d11e19ecf | ||
|
|
9608ec0925 | ||
|
|
02a662e923 | ||
|
|
e181ebeaae | ||
|
|
19665cd8cf | ||
|
|
1e1c649398 | ||
|
|
1a1fcbc3bc | ||
|
|
adc0d9e477 | ||
|
|
528d5e67e5 | ||
|
|
d283a35a10 | ||
|
|
f6ba5bcf01 | ||
|
|
52b102cfa8 | ||
|
|
cbd74b3167 | ||
|
|
e76665bcfb | ||
|
|
fdc6d6e787 | ||
|
|
e30f7fbe9c | ||
|
|
ec141dbfd6 | ||
|
|
77dffd551d | ||
|
|
3eb68ef830 | ||
|
|
1ea3879aae | ||
|
|
201677ae62 | ||
|
|
4b1e5056d5 | ||
|
|
bf507cd5d4 | ||
|
|
a8204f8cde | ||
|
|
043581e0c9 | ||
|
|
bb541e22c3 | ||
|
|
00e3da1a92 | ||
|
|
027b775fcd | ||
|
|
c26c342cc1 | ||
|
|
99987b3f27 | ||
|
|
b850516a88 | ||
|
|
72d879cf82 | ||
|
|
59874c5734 | ||
|
|
12f2a1f694 | ||
|
|
d713806263 | ||
|
|
b924dfcd8c | ||
|
|
8a24518c4c | ||
|
|
39aadf7099 | ||
|
|
1275ccf8c1 | ||
|
|
909b9aade4 | ||
|
|
3ccc91d4d4 | ||
|
|
e61ecb4496 | ||
|
|
7742631207 | ||
|
|
d83402fc65 | ||
|
|
9519d47d57 | ||
|
|
861504d5f6 | ||
|
|
e872351170 | ||
|
|
498b14ee1d | ||
|
|
a938449b29 | ||
|
|
ca8df30ec3 | ||
|
|
adb5b293f0 | ||
|
|
9b33575a70 | ||
|
|
022d7625d2 | ||
|
|
6d2d663d3e | ||
|
|
f275835cd3 | ||
|
|
472aa12767 | ||
|
|
becb073aac | ||
|
|
88011a7c65 | ||
|
|
0aac7315c3 | ||
|
|
ee289844ed | ||
|
|
c2d5c1f17c | ||
|
|
65f5dd579b | ||
|
|
654c14490e | ||
|
|
d617e67199 | ||
|
|
1acec39525 | ||
|
|
8613b3573c | ||
|
|
0bf248d355 | ||
|
|
c1877b7cf5 | ||
|
|
61cbc39c4a | ||
|
|
efe1a67e84 | ||
|
|
af022683fe | ||
|
|
391ca4ca26 | ||
|
|
365ff6b030 | ||
|
|
f339d8a4d3 | ||
|
|
670c300625 | ||
|
|
ecea47320c | ||
|
|
f33c030ebb | ||
|
|
fbc12a358c | ||
|
|
13f40de5bb | ||
|
|
7fa9a30b89 | ||
|
|
75a945d3d3 | ||
|
|
83858cd7ca | ||
|
|
320ebf6a20 | ||
|
|
dc9b498ee8 | ||
|
|
31488e79da | ||
|
|
febb1d2e08 | ||
|
|
7f15a46081 | ||
|
|
ac31bee4ca | ||
|
|
4ea37b4579 | ||
|
|
d01f960e4f | ||
|
|
77842b72d7 | ||
|
|
df8cd23ae7 | ||
|
|
dc13308784 | ||
|
|
b1c87cd98c | ||
|
|
5fc15bcfa4 | ||
|
|
33b350a612 | ||
|
|
0f0e38b0bf | ||
|
|
717809c52c | ||
|
|
b95a17d8d3 | ||
|
|
af09e58fb0 | ||
|
|
51c971f089 | ||
|
|
e90738a6e2 | ||
|
|
992938f0ae | ||
|
|
37a5f9cb15 | ||
|
|
41e314247d | ||
|
|
dcec327942 | ||
|
|
dd9917d3f3 | ||
|
|
32fdad1411 | ||
|
|
3a6769d3b9 | ||
|
|
1270364796 | ||
|
|
7e81f23de6 | ||
|
|
7caa68a141 | ||
|
|
e30772f0c1 | ||
|
|
1aaaeb49ed | ||
|
|
81c622bcec | ||
|
|
3b5eee59ee | ||
|
|
103b501af7 | ||
|
|
116357e2f6 | ||
|
|
545213421b | ||
|
|
4a43191c31 | ||
|
|
7acf2b3acf |
152 changed files with 11956 additions and 6808 deletions
|
|
@ -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
2
.github/CODEOWNERS
vendored
|
|
@ -3,3 +3,5 @@
|
|||
|
||||
# Specific ownerships:
|
||||
/beets/metadata_plugins.py @semohr
|
||||
/beetsplug/titlecase.py @henry-oberholtzer
|
||||
/beetsplug/mbpseudo.py @asardaes
|
||||
|
|
|
|||
2
.github/workflows/changelog_reminder.yaml
vendored
2
.github/workflows/changelog_reminder.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
.github/workflows/integration_test.yaml
vendored
10
.github/workflows/integration_test.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
.github/workflows/make_release.yaml
vendored
14
.github/workflows/make_release.yaml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -95,5 +95,5 @@ ENV/
|
|||
# pyright
|
||||
pyrightconfig.json
|
||||
|
||||
# Versioning
|
||||
beets/_version.py
|
||||
# Pyrefly
|
||||
pyrefly.toml
|
||||
|
|
|
|||
|
|
@ -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 you’re 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
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
설치
|
||||
-------
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__ = [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
110
beets/plugins.py
110
beets/plugins.py
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2490
beets/ui/commands.py
2490
beets/ui/commands.py
File diff suppressed because it is too large
Load diff
67
beets/ui/commands/__init__.py
Normal file
67
beets/ui/commands/__init__.py
Normal 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"]
|
||||
117
beets/ui/commands/completion.py
Normal file
117
beets/ui/commands/completion.py
Normal 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"
|
||||
93
beets/ui/commands/config.py
Normal file
93
beets/ui/commands/config.py
Normal 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
|
||||
41
beets/ui/commands/fields.py
Normal file
41
beets/ui/commands/fields.py
Normal 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
22
beets/ui/commands/help.py
Normal 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()
|
||||
341
beets/ui/commands/import_/__init__.py
Normal file
341
beets/ui/commands/import_/__init__.py
Normal 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
|
||||
570
beets/ui/commands/import_/display.py
Normal file
570
beets/ui/commands/import_/display.py
Normal 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)
|
||||
550
beets/ui/commands/import_/session.py
Normal file
550
beets/ui/commands/import_/session.py
Normal 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
25
beets/ui/commands/list.py
Normal 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
162
beets/ui/commands/modify.py
Normal 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
200
beets/ui/commands/move.py
Normal 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
|
||||
84
beets/ui/commands/remove.py
Normal file
84
beets/ui/commands/remove.py
Normal 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
|
||||
62
beets/ui/commands/stats.py
Normal file
62
beets/ui/commands/stats.py
Normal 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
196
beets/ui/commands/update.py
Normal 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
|
||||
29
beets/ui/commands/utils.py
Normal file
29
beets/ui/commands/utils.py
Normal 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
|
||||
23
beets/ui/commands/version.py
Normal file
23
beets/ui/commands/version.py
Normal 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"]
|
||||
60
beets/ui/commands/write.py
Normal file
60
beets/ui/commands/write.py
Normal 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
|
||||
|
|
@ -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}'")
|
||||
|
|
|
|||
|
|
@ -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
60
beets/util/deprecation.py
Normal 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}'")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -27,7 +27,16 @@ import gi
|
|||
|
||||
from beets import ui
|
||||
|
||||
gi.require_version("Gst", "1.0")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
167
beetsplug/importsource.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
366
beetsplug/mbpseudo.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
236
beetsplug/titlecase.py
Normal 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}")
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
14
docs/conf.py
14
docs/conf.py
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ configuration files, respectively.
|
|||
|
||||
plugins/index
|
||||
library
|
||||
paths
|
||||
importer
|
||||
cli
|
||||
../api/index
|
||||
|
|
|
|||
64
docs/dev/paths.rst
Normal file
64
docs/dev/paths.rst
Normal 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"))
|
||||
|
|
@ -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::
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
142
docs/extensions/conf.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ guide.
|
|||
:maxdepth: 1
|
||||
|
||||
main
|
||||
installation
|
||||
tagger
|
||||
advanced
|
||||
|
|
|
|||
179
docs/guides/installation.rst
Normal file
179
docs/guides/installation.rst
Normal 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
|
||||
|
|
@ -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
|
||||
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
|
||||
process, see :doc:`tagger`.
|
||||
There are two good ways to bring your *existing* library into beets database.
|
||||
|
||||
If you've got time and want to tag all your music right once and for all, do
|
||||
this:
|
||||
.. tab-set::
|
||||
|
||||
::
|
||||
.. tab-item:: Autotag (Recommended)
|
||||
|
||||
$ beet import /path/to/my/music
|
||||
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.
|
||||
|
||||
(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:
|
||||
.. code-block:: console
|
||||
|
||||
::
|
||||
beet import /a/chunk/of/my/library
|
||||
|
||||
$ beet import -A /my/huge/mp3/library
|
||||
.. warning::
|
||||
|
||||
Note that you just need to add ``-A`` for "don't autotag".
|
||||
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.
|
||||
|
||||
Adding More Music
|
||||
-----------------
|
||||
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 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:
|
||||
|
||||
::
|
||||
.. tab-item:: Quick Import
|
||||
|
||||
$ beet import ~/some_great_album
|
||||
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.
|
||||
|
||||
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.
|
||||
To use this method, run:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
beet import --noautotag /my/huge/mp3/library
|
||||
|
||||
The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata.
|
||||
|
||||
.. admonition:: More Import Options
|
||||
|
||||
The ``beet import`` command has many options to customize its behavior. For
|
||||
a full list, type ``beet help import`` or see the :ref:`import command
|
||||
reference <import-cmd>`.
|
||||
|
||||
Adding More Music Later
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When you acquire new music, use the same ``beet import`` command to add it to
|
||||
your library:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
beet import ~/new_totally_not_ripped_album
|
||||
|
||||
This will apply the same autotagging process to your new additions. For
|
||||
alternative import behaviors, consult the options mentioned above.
|
||||
|
||||
Seeing Your Music
|
||||
-----------------
|
||||
|
||||
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>`_.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
--------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,33 +113,51 @@ 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
|
||||
"Tha Jaxx" or "The Basement Jaxx". You can select any combination of these
|
||||
config options to control where beets writes and stores the variation credit.
|
||||
The default, shown below, writes variations to the artist_credit field.
|
||||
|
||||
.. code-block:: yaml
|
||||
.. code-block:: yaml
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
80
docs/plugins/importsource.rst
Normal file
80
docs/plugins/importsource.rst
Normal 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
Loading…
Reference in a new issue