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
|
33f1a5d0bef8ca08be79ee7a0d02a018d502680d
|
||||||
# Moved art.py utility module from beets into beetsplug
|
# Moved art.py utility module from beets into beetsplug
|
||||||
28aee0fde463f1e18dfdba1994e2bdb80833722f
|
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:
|
# Specific ownerships:
|
||||||
/beets/metadata_plugins.py @semohr
|
/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:
|
check_changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Get all updated Python files
|
- name: Get all updated Python files
|
||||||
id: changed-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
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest, windows-latest]
|
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 }}
|
runs-on: ${{ matrix.platform }}
|
||||||
env:
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- name: Setup Python with poetry caching
|
- name: Setup Python with poetry caching
|
||||||
# poetry cache requires poetry to already be installed, weirdly
|
# poetry cache requires poetry to already be installed, weirdly
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -39,7 +39,15 @@ jobs:
|
||||||
if: matrix.platform == 'ubuntu-latest'
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
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
|
- name: Get changed lyrics files
|
||||||
id: lyrics-update
|
id: lyrics-update
|
||||||
|
|
@ -90,10 +98,10 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Get the coverage report
|
- name: Get the coverage report
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
|
|
||||||
|
|
|
||||||
10
.github/workflows/integration_test.yaml
vendored
10
.github/workflows/integration_test.yaml
vendored
|
|
@ -3,16 +3,20 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * SUN" # run every Sunday at midnight
|
- cron: "0 0 * * SUN" # run every Sunday at midnight
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test_integration:
|
test_integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ concurrency:
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: 3.9
|
PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changed-files:
|
changed-files:
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }}
|
changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }}
|
||||||
changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }}
|
changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Get changed docs files
|
- name: Get changed docs files
|
||||||
id: changed-doc-files
|
id: changed-doc-files
|
||||||
uses: tj-actions/changed-files@v46
|
uses: tj-actions/changed-files@v46
|
||||||
|
|
@ -56,10 +56,10 @@ jobs:
|
||||||
name: Check formatting
|
name: Check formatting
|
||||||
needs: changed-files
|
needs: changed-files
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -77,10 +77,10 @@ jobs:
|
||||||
name: Check linting
|
name: Check linting
|
||||||
needs: changed-files
|
needs: changed-files
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -97,10 +97,10 @@ jobs:
|
||||||
name: Check types with mypy
|
name: Check types with mypy
|
||||||
needs: changed-files
|
needs: changed-files
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -120,10 +120,10 @@ jobs:
|
||||||
name: Check docs
|
name: Check docs
|
||||||
needs: changed-files
|
needs: changed-files
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
14
.github/workflows/make_release.yaml
vendored
14
.github/workflows/make_release.yaml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: 3.9
|
PYTHON_VERSION: "3.10"
|
||||||
NEW_VERSION: ${{ inputs.version }}
|
NEW_VERSION: ${{ inputs.version }}
|
||||||
NEW_TAG: v${{ inputs.version }}
|
NEW_TAG: v${{ inputs.version }}
|
||||||
|
|
||||||
|
|
@ -17,10 +17,10 @@ jobs:
|
||||||
name: Bump version, commit and create tag
|
name: Bump version, commit and create tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -45,13 +45,13 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
changelog: ${{ steps.generate_changelog.outputs.changelog }}
|
changelog: ${{ steps.generate_changelog.outputs.changelog }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.NEW_TAG }}
|
ref: ${{ env.NEW_TAG }}
|
||||||
|
|
||||||
- name: Install Python tools
|
- name: Install Python tools
|
||||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: poetry
|
cache: poetry
|
||||||
|
|
@ -92,7 +92,7 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download all the dists
|
- name: Download all the dists
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: python-package-distributions
|
name: python-package-distributions
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|
@ -107,7 +107,7 @@ jobs:
|
||||||
CHANGELOG: ${{ needs.build.outputs.changelog }}
|
CHANGELOG: ${{ needs.build.outputs.changelog }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all the dists
|
- name: Download all the dists
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: python-package-distributions
|
name: python-package-distributions
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -95,5 +95,5 @@ ENV/
|
||||||
# pyright
|
# pyright
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
# Versioning
|
# Pyrefly
|
||||||
beets/_version.py
|
pyrefly.toml
|
||||||
|
|
|
||||||
|
|
@ -124,12 +124,12 @@ command. Instead, you can activate the virtual environment in your shell with:
|
||||||
|
|
||||||
$ poetry shell
|
$ 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:
|
commands directly, for example:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
$ (beets-py3.9) pytest
|
$ (beets-py3.10) pytest
|
||||||
|
|
||||||
Additionally, poethepoet_ task runner assists us with the most common
|
Additionally, poethepoet_ task runner assists us with the most common
|
||||||
operations. Formatting, linting, testing are defined as ``poe`` tasks in
|
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
|
Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent
|
||||||
documentation formatting and check for any issues.
|
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
|
Editor Settings
|
||||||
~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ simple if you know a little Python.
|
||||||
|
|
||||||
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
|
.. _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
|
Install
|
||||||
-------
|
-------
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
||||||
|
|
||||||
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
|
.. _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
|
import confuse
|
||||||
|
|
||||||
# Version management using poetry-dynamic-versioning
|
from .util.deprecation import deprecate_imports
|
||||||
from ._version import __version__, __version_tuple__
|
|
||||||
from .util import deprecate_imports
|
|
||||||
|
|
||||||
|
__version__ = "2.5.1"
|
||||||
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
"""Handle deprecated imports."""
|
"""Handle deprecated imports."""
|
||||||
return deprecate_imports(
|
return deprecate_imports(
|
||||||
old_module=__name__,
|
__name__,
|
||||||
new_module_by_name={
|
{"art": "beetsplug._utils", "vfs": "beetsplug._utils"},
|
||||||
"art": "beetsplug._utils",
|
name,
|
||||||
"vfs": "beetsplug._utils",
|
|
||||||
},
|
|
||||||
name=name,
|
|
||||||
version="3.0.0",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,6 +50,3 @@ class IncludeLazyConfig(confuse.LazyConfig):
|
||||||
|
|
||||||
|
|
||||||
config = IncludeLazyConfig("beets", __name__)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import warnings
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from typing import TYPE_CHECKING, Union
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beets import config, logging
|
from beets import config, logging
|
||||||
|
|
||||||
# Parts of external interface.
|
# Parts of external interface.
|
||||||
from beets.util import unique_list
|
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 .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
|
||||||
from .match import Proposal, Recommendation, tag_album, tag_item
|
from .match import Proposal, Recommendation, tag_album, tag_item
|
||||||
|
|
||||||
|
|
@ -37,18 +36,13 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
if name == "current_metadata":
|
if name == "current_metadata":
|
||||||
warnings.warn(
|
deprecate_for_maintainers(
|
||||||
(
|
f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'"
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return import_module("beets.util").get_most_common_tags
|
return import_module("beets.util").get_most_common_tags
|
||||||
|
|
||||||
return deprecate_imports(
|
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(
|
def _apply_metadata(
|
||||||
info: Union[AlbumInfo, TrackInfo],
|
info: AlbumInfo | TrackInfo,
|
||||||
db_obj: Union[Album, Item],
|
db_obj: Album | Item,
|
||||||
nullable_fields: Sequence[str] = [],
|
nullable_fields: Sequence[str] = [],
|
||||||
):
|
):
|
||||||
"""Set the db_obj's metadata to match the info."""
|
"""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 lap
|
||||||
import numpy as np
|
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.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
|
||||||
from beets.util import get_most_common_tags
|
from beets.util import get_most_common_tags
|
||||||
|
|
||||||
|
|
@ -274,12 +274,17 @@ def tag_album(
|
||||||
log.debug("Searching for album ID: {}", search_id)
|
log.debug("Searching for album ID: {}", search_id)
|
||||||
if info := metadata_plugins.album_for_id(search_id):
|
if info := metadata_plugins.album_for_id(search_id):
|
||||||
_add_candidate(items, candidates, info)
|
_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.
|
# Use existing metadata or text search.
|
||||||
else:
|
else:
|
||||||
# Try search based on current ID.
|
# Try search based on current ID.
|
||||||
if info := match_by_id(items):
|
if info := match_by_id(items):
|
||||||
_add_candidate(items, candidates, info)
|
_add_candidate(items, candidates, info)
|
||||||
|
for candidate in candidates.values():
|
||||||
|
plugins.send("album_matched", match=candidate)
|
||||||
|
|
||||||
rec = _recommendation(list(candidates.values()))
|
rec = _recommendation(list(candidates.values()))
|
||||||
log.debug("Album ID match recommendation is {}", rec)
|
log.debug("Album ID match recommendation is {}", rec)
|
||||||
if candidates and not config["import"]["timid"]:
|
if candidates and not config["import"]["timid"]:
|
||||||
|
|
@ -313,6 +318,8 @@ def tag_album(
|
||||||
items, search_artist, search_album, va_likely
|
items, search_artist, search_album, va_likely
|
||||||
):
|
):
|
||||||
_add_candidate(items, candidates, matched_candidate)
|
_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))
|
log.debug("Evaluating {} candidates.", len(candidates))
|
||||||
# Sort and get the recommendation.
|
# Sort and get the recommendation.
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,16 @@ import threading
|
||||||
import time
|
import time
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from collections import defaultdict
|
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 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 typing_extensions import TypeVar # default value support
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
@ -940,10 +947,10 @@ class Transaction:
|
||||||
|
|
||||||
def __exit__(
|
def __exit__(
|
||||||
self,
|
self,
|
||||||
exc_type: type[Exception],
|
exc_type: type[BaseException] | None,
|
||||||
exc_value: Exception,
|
exc_value: BaseException | None,
|
||||||
traceback: TracebackType,
|
traceback: TracebackType | None,
|
||||||
):
|
) -> bool | None:
|
||||||
"""Complete a transaction. This must be the most recently
|
"""Complete a transaction. This must be the most recently
|
||||||
entered but not yet exited transaction. If it is the last active
|
entered but not yet exited transaction. If it is the last active
|
||||||
transaction, the database updates are committed.
|
transaction, the database updates are committed.
|
||||||
|
|
@ -965,6 +972,8 @@ class Transaction:
|
||||||
):
|
):
|
||||||
raise DBCustomFunctionError()
|
raise DBCustomFunctionError()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self, statement: str, subvals: Sequence[SQLiteType] = ()
|
self, statement: str, subvals: Sequence[SQLiteType] = ()
|
||||||
) -> list[sqlite3.Row]:
|
) -> list[sqlite3.Row]:
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Sequence
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beets import config, dbcore, library, logging, plugins, util
|
from beets import config, dbcore, library, logging, plugins, util
|
||||||
from beets.importer.tasks import Action
|
from beets.importer.tasks import Action
|
||||||
|
|
@ -25,6 +25,8 @@ from . import stages as stagefuncs
|
||||||
from .state import ImportState
|
from .state import ImportState
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from beets.util import PathBytes
|
from beets.util import PathBytes
|
||||||
|
|
||||||
from .tasks import ImportTask
|
from .tasks import ImportTask
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Callable
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beets import config, plugins
|
from beets import config, plugins
|
||||||
from beets.util import MoveOperation, displayable_path, pipeline
|
from beets.util import MoveOperation, displayable_path, pipeline
|
||||||
|
|
@ -30,6 +30,8 @@ from .tasks import (
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from beets import library
|
from beets import library
|
||||||
|
|
||||||
from .session import ImportSession
|
from .session import ImportSession
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import mediafile
|
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 .exceptions import FileOperationError, ReadError, WriteError
|
||||||
from .library import Library
|
from .library import Library
|
||||||
|
|
@ -13,7 +13,7 @@ NEW_MODULE_BY_NAME = dict.fromkeys(
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
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__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ calls (`debug`, `info`, etc).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from logging import (
|
from logging import (
|
||||||
|
|
@ -37,7 +38,7 @@ from logging import (
|
||||||
RootLogger,
|
RootLogger,
|
||||||
StreamHandler,
|
StreamHandler,
|
||||||
)
|
)
|
||||||
from typing import TYPE_CHECKING, Any, Mapping, TypeVar, Union, overload
|
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
|
|
@ -54,6 +55,8 @@ __all__ = [
|
||||||
]
|
]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
|
|
@ -66,6 +69,15 @@ if TYPE_CHECKING:
|
||||||
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
|
_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:
|
def _logsafe(val: T) -> str | T:
|
||||||
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
|
"""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
|
# type, and (b) warn the developer if they do this for other
|
||||||
# bytestrings.
|
# bytestrings.
|
||||||
return val.decode("utf-8", "replace")
|
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
|
# Other objects are used as-is so field access, etc., still works in
|
||||||
# the format string. Relies on a working __str__ implementation.
|
# the format string. Relies on a working __str__ implementation.
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,11 @@
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
import mediafile
|
import mediafile
|
||||||
|
|
||||||
warnings.warn(
|
from .util.deprecation import deprecate_for_maintainers
|
||||||
"beets.mediafile is deprecated; use mediafile instead",
|
|
||||||
# Show the location of the `import mediafile` statement as the warning's
|
deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2)
|
||||||
# source, rather than this file, such that the offending module can be
|
|
||||||
# identified easily.
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import everything from the mediafile module into this module.
|
# Import everything from the mediafile module into this module.
|
||||||
for key, value in mediafile.__dict__.items():
|
for key, value in mediafile.__dict__.items():
|
||||||
|
|
@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items():
|
||||||
globals()[key] = value
|
globals()[key] = value
|
||||||
|
|
||||||
# Cleanup namespace.
|
# Cleanup namespace.
|
||||||
del key, value, warnings, mediafile
|
del key, value, mediafile
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
import abc
|
import abc
|
||||||
import re
|
import re
|
||||||
from functools import cache, cached_property
|
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
|
import unidecode
|
||||||
from confuse import NotFoundError
|
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
|
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
from .autotag.hooks import AlbumInfo, Item, TrackInfo
|
from .autotag.hooks import AlbumInfo, Item, TrackInfo
|
||||||
|
|
||||||
|
|
|
||||||
110
beets/plugins.py
110
beets/plugins.py
|
|
@ -20,12 +20,10 @@ import abc
|
||||||
import inspect
|
import inspect
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from functools import wraps
|
from functools import cached_property, wraps
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import GenericAlias
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
|
||||||
|
|
||||||
import mediafile
|
import mediafile
|
||||||
|
|
@ -34,6 +32,7 @@ from typing_extensions import ParamSpec
|
||||||
import beets
|
import beets
|
||||||
from beets import logging
|
from beets import logging
|
||||||
from beets.util import unique_list
|
from beets.util import unique_list
|
||||||
|
from beets.util.deprecation import deprecate_for_maintainers, deprecate_for_user
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
|
@ -72,6 +71,7 @@ EventType = Literal[
|
||||||
"album_imported",
|
"album_imported",
|
||||||
"album_removed",
|
"album_removed",
|
||||||
"albuminfo_received",
|
"albuminfo_received",
|
||||||
|
"album_matched",
|
||||||
"before_choose_candidate",
|
"before_choose_candidate",
|
||||||
"before_item_moved",
|
"before_item_moved",
|
||||||
"cli_exit",
|
"cli_exit",
|
||||||
|
|
@ -151,9 +151,9 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
list
|
list
|
||||||
)
|
)
|
||||||
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
|
listeners: ClassVar[dict[EventType, list[Listener]]] = defaultdict(list)
|
||||||
template_funcs: TFuncMap[str] | None = None
|
template_funcs: ClassVar[TFuncMap[str]] | TFuncMap[str] = {} # type: ignore[valid-type]
|
||||||
template_fields: TFuncMap[Item] | None = None
|
template_fields: ClassVar[TFuncMap[Item]] | TFuncMap[Item] = {} # type: ignore[valid-type]
|
||||||
album_template_fields: TFuncMap[Album] | None = None
|
album_template_fields: ClassVar[TFuncMap[Album]] | TFuncMap[Album] = {} # type: ignore[valid-type]
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
config: ConfigView
|
config: ConfigView
|
||||||
|
|
@ -184,20 +184,32 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
warnings.warn(
|
deprecate_for_maintainers(
|
||||||
f"{cls.__name__} is used as a legacy metadata source. "
|
(
|
||||||
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
|
f"'{cls.__name__}' is used as a legacy metadata source since it"
|
||||||
"Support for this will be removed in the v3.0.0 release!",
|
" inherits 'beets.plugins.BeetsPlugin'. Support for this"
|
||||||
DeprecationWarning,
|
),
|
||||||
|
"'beets.metadata_plugins.MetadataSourcePlugin'",
|
||||||
stacklevel=3,
|
stacklevel=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
method: property | cached_property[Any] | Callable[..., Any]
|
||||||
for name, method in inspect.getmembers(
|
for name, method in inspect.getmembers(
|
||||||
MetadataSourcePlugin,
|
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)
|
inspect.isfunction(f)
|
||||||
and f.__name__ not in MetadataSourcePlugin.__abstractmethods__
|
and f.__name__
|
||||||
and not hasattr(cls, f.__name__)
|
and not getattr(f, "__isabstractmethod__", False)
|
||||||
|
and not hasattr(BeetsPlugin, f.__name__)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
setattr(cls, name, method)
|
setattr(cls, name, method)
|
||||||
|
|
@ -208,8 +220,8 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
self.name = name or self.__module__.split(".")[-1]
|
self.name = name or self.__module__.split(".")[-1]
|
||||||
self.config = beets.config[self.name]
|
self.config = beets.config[self.name]
|
||||||
|
|
||||||
# Set class attributes if they are not already set
|
# If the class attributes are not set, initialize as instance attributes.
|
||||||
# for the type of plugin.
|
# TODO: Revise with v3.0.0, see also type: ignore[valid-type] above
|
||||||
if not self.template_funcs:
|
if not self.template_funcs:
|
||||||
self.template_funcs = {}
|
self.template_funcs = {}
|
||||||
if not self.template_fields:
|
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
|
# In order to verify the config we need to make sure the plugin is fully
|
||||||
# configured (plugins usually add the default configuration *after*
|
# configured (plugins usually add the default configuration *after*
|
||||||
# calling super().__init__()).
|
# 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.
|
"""Verify plugin configuration.
|
||||||
|
|
||||||
If deprecated 'source_weight' option is explicitly set by the user, they
|
If deprecated 'source_weight' option is explicitly set by the user, they
|
||||||
|
|
@ -245,16 +257,19 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
):
|
):
|
||||||
return
|
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:
|
for source in self.config.root().sources:
|
||||||
if "source_weight" in (source.get(self.name) or {}):
|
if "source_weight" in (source.get(self.name) or {}):
|
||||||
if source.filename: # user config
|
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
|
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]:
|
def commands(self) -> Sequence[Subcommand]:
|
||||||
"""Should return a list of beets.ui.Subcommand objects for
|
"""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]:
|
def helper(func: TFunc[str]) -> TFunc[str]:
|
||||||
if cls.template_funcs is None:
|
|
||||||
cls.template_funcs = {}
|
|
||||||
cls.template_funcs[name] = func
|
cls.template_funcs[name] = func
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
@ -373,8 +386,6 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def helper(func: TFunc[Item]) -> TFunc[Item]:
|
def helper(func: TFunc[Item]) -> TFunc[Item]:
|
||||||
if cls.template_fields is None:
|
|
||||||
cls.template_fields = {}
|
|
||||||
cls.template_fields[name] = func
|
cls.template_fields[name] = func
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
@ -403,16 +414,22 @@ def get_plugin_names() -> list[str]:
|
||||||
# *contain* a `beetsplug` package.
|
# *contain* a `beetsplug` package.
|
||||||
sys.path += paths
|
sys.path += paths
|
||||||
plugins = unique_list(beets.config["plugins"].as_str_seq())
|
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": []})
|
beets.config.add({"disabled_plugins": []})
|
||||||
disabled_plugins = set(beets.config["disabled_plugins"].as_str_seq())
|
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]
|
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
|
Attempts to import the plugin module, locate the appropriate plugin class
|
||||||
within it, and return an instance. Handles import failures gracefully and
|
within it, and return an instance. Handles import failures gracefully and
|
||||||
logs warnings for missing plugins or loading errors.
|
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:
|
||||||
try:
|
try:
|
||||||
|
|
@ -429,12 +452,9 @@ def _get_plugin(name: str) -> BeetsPlugin | None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise PluginImportError(name) from exc
|
raise PluginImportError(name) from exc
|
||||||
|
|
||||||
for obj in namespace.__dict__.values():
|
for obj in reversed(namespace.__dict__.values()):
|
||||||
if (
|
if (
|
||||||
inspect.isclass(obj)
|
inspect.isclass(obj)
|
||||||
and not isinstance(
|
|
||||||
obj, GenericAlias
|
|
||||||
) # seems to be needed for python <= 3.9 only
|
|
||||||
and issubclass(obj, BeetsPlugin)
|
and issubclass(obj, BeetsPlugin)
|
||||||
and obj != BeetsPlugin
|
and obj != BeetsPlugin
|
||||||
and not inspect.isabstract(obj)
|
and not inspect.isabstract(obj)
|
||||||
|
|
@ -551,7 +571,6 @@ def template_funcs() -> TFuncMap[str]:
|
||||||
"""
|
"""
|
||||||
funcs: TFuncMap[str] = {}
|
funcs: TFuncMap[str] = {}
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
if plugin.template_funcs:
|
|
||||||
funcs.update(plugin.template_funcs)
|
funcs.update(plugin.template_funcs)
|
||||||
return funcs
|
return funcs
|
||||||
|
|
||||||
|
|
@ -578,14 +597,13 @@ F = TypeVar("F")
|
||||||
|
|
||||||
|
|
||||||
def _check_conflicts_and_merge(
|
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:
|
) -> None:
|
||||||
"""Check the provided template functions for conflicts and merge into funcs.
|
"""Check the provided template functions for conflicts and merge into funcs.
|
||||||
|
|
||||||
Raises a `PluginConflictError` if a plugin defines template functions
|
Raises a `PluginConflictError` if a plugin defines template functions
|
||||||
for fields that another plugin has already defined template functions for.
|
for fields that another plugin has already defined template functions for.
|
||||||
"""
|
"""
|
||||||
if plugin_funcs:
|
|
||||||
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
|
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
|
||||||
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
|
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
|
||||||
raise PluginConflictError(
|
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"
|
"""Return a regular expression that matches phrases like "featuring"
|
||||||
that separate a main artist or a song title from secondary artists.
|
that separate a main artist or a song title from secondary artists.
|
||||||
The `for_artist` option determines whether the regex should be
|
The `for_artist` option determines whether the regex should be
|
||||||
suitable for matching artist fields (the default) or title fields.
|
suitable for matching artist fields (the default) or title fields.
|
||||||
"""
|
"""
|
||||||
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
|
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
|
||||||
|
if isinstance(custom_words, list):
|
||||||
|
feat_words += custom_words
|
||||||
if for_artist:
|
if for_artist:
|
||||||
feat_words += ["with", "vs", "and", "con", "&"]
|
feat_words += ["with", "vs", "and", "con", "&"]
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,11 @@ def item(lib=None, **kwargs):
|
||||||
|
|
||||||
# Dummy import session.
|
# Dummy import session.
|
||||||
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
|
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)
|
return cls(lib, loghandler, paths, query)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||||
from beets.importer import ImportSession
|
from beets.importer import ImportSession
|
||||||
from beets.library import Item, Library
|
from beets.library import Item, Library
|
||||||
from beets.test import _common
|
from beets.test import _common
|
||||||
from beets.ui.commands import TerminalImportSession
|
from beets.ui.commands.import_.session import TerminalImportSession
|
||||||
from beets.util import (
|
from beets.util import (
|
||||||
MoveOperation,
|
MoveOperation,
|
||||||
bytestring_path,
|
bytestring_path,
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,15 @@ import errno
|
||||||
import optparse
|
import optparse
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import struct
|
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Any, Callable, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
|
|
||||||
|
|
@ -40,8 +39,12 @@ from beets import config, library, logging, plugins, util
|
||||||
from beets.dbcore import db
|
from beets.dbcore import db
|
||||||
from beets.dbcore import query as db_query
|
from beets.dbcore import query as db_query
|
||||||
from beets.util import as_string
|
from beets.util import as_string
|
||||||
|
from beets.util.deprecation import deprecate_for_maintainers
|
||||||
from beets.util.functemplate import template
|
from beets.util.functemplate import template
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
try:
|
try:
|
||||||
|
|
@ -111,11 +114,7 @@ def decargs(arglist):
|
||||||
.. deprecated:: 2.4.0
|
.. deprecated:: 2.4.0
|
||||||
This function will be removed in 3.0.0.
|
This function will be removed in 3.0.0.
|
||||||
"""
|
"""
|
||||||
warnings.warn(
|
deprecate_for_maintainers("'beets.ui.decargs'")
|
||||||
"decargs() is deprecated and will be removed in version 3.0.0.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
return arglist
|
return arglist
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -699,27 +698,11 @@ def get_replacements():
|
||||||
return replacements
|
return replacements
|
||||||
|
|
||||||
|
|
||||||
def term_width():
|
@cache
|
||||||
|
def term_width() -> int:
|
||||||
"""Get the width (columns) of the terminal."""
|
"""Get the width (columns) of the terminal."""
|
||||||
fallback = config["ui"]["terminal_width"].get(int)
|
columns, _ = shutil.get_terminal_size(fallback=(0, 0))
|
||||||
|
return columns if columns else 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
|
|
||||||
|
|
||||||
|
|
||||||
def split_into_lines(string, width_tuple):
|
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}"
|
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
|
"""Given a Model object, print a list of changes from its pristine
|
||||||
version stored in the database. Return a boolean indicating whether
|
version stored in the database. Return a boolean indicating whether
|
||||||
any changes were found.
|
any changes were found.
|
||||||
|
|
@ -1117,7 +1102,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Print changes.
|
# Print changes.
|
||||||
if changes or always:
|
if print_obj and (changes or always):
|
||||||
print_(format(old))
|
print_(format(old))
|
||||||
if changes:
|
if changes:
|
||||||
print_("\n".join(changes))
|
print_("\n".join(changes))
|
||||||
|
|
@ -1125,76 +1110,9 @@ def show_model_changes(new, old=None, fields=None, always=False):
|
||||||
return bool(changes)
|
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.
|
# 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):
|
class CommonOptionsParser(optparse.OptionParser):
|
||||||
"""Offers a simple way to add common formatting options.
|
"""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 subargs[0] == "config"
|
||||||
and ("-e" in subargs or "--edit" in subargs)
|
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)
|
test_lib = bool(lib)
|
||||||
subcommands, lib = _setup(options, 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 sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import cache
|
from functools import cache
|
||||||
|
|
@ -41,12 +40,12 @@ from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
AnyStr,
|
AnyStr,
|
||||||
Callable,
|
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Generic,
|
Generic,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
@ -168,6 +167,12 @@ class MoveOperation(Enum):
|
||||||
REFLINK_AUTO = 5
|
REFLINK_AUTO = 5
|
||||||
|
|
||||||
|
|
||||||
|
class PromptChoice(NamedTuple):
|
||||||
|
short: str
|
||||||
|
long: str
|
||||||
|
callback: Any
|
||||||
|
|
||||||
|
|
||||||
def normpath(path: PathLike) -> bytes:
|
def normpath(path: PathLike) -> bytes:
|
||||||
"""Provide the canonical form of the path suitable for storing in
|
"""Provide the canonical form of the path suitable for storing in
|
||||||
the database.
|
the database.
|
||||||
|
|
@ -577,10 +582,14 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
|
||||||
if samefile(path, dest):
|
if samefile(path, dest):
|
||||||
return
|
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))
|
raise FilesystemError("file exists", "rename", (path, dest))
|
||||||
try:
|
try:
|
||||||
os.link(syspath(path), syspath(dest))
|
dest_path.hardlink_to(origin_path)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
raise FilesystemError(
|
raise FilesystemError(
|
||||||
"OS does not support hard links.link",
|
"OS does not support hard links.link",
|
||||||
|
|
@ -1052,7 +1061,7 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
|
||||||
pool.join()
|
pool.join()
|
||||||
|
|
||||||
|
|
||||||
class cached_classproperty:
|
class cached_classproperty(Generic[T]):
|
||||||
"""Descriptor implementing cached class properties.
|
"""Descriptor implementing cached class properties.
|
||||||
|
|
||||||
Provides class-level dynamic property behavior where the getter function is
|
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.
|
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,
|
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
|
||||||
# however, `mypy` is unable to see this as a **class** property, and thinks
|
# 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]], ...]"
|
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
|
||||||
#
|
#
|
||||||
# Therefore, we just use `Any` here, which is not ideal, but works.
|
# 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."""
|
"""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."""
|
"""Capture the attribute name this descriptor is assigned to."""
|
||||||
self.name = name
|
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."""
|
"""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:
|
if key not in self.cache:
|
||||||
self.cache[key] = self.getter(owner)
|
self.cache[key] = self.getter(owner)
|
||||||
|
|
||||||
return self.cache[key]
|
return cast(T, self.cache[key])
|
||||||
|
|
||||||
|
|
||||||
class LazySharedInstance(Generic[T]):
|
class LazySharedInstance(Generic[T]):
|
||||||
|
|
@ -1191,26 +1200,3 @@ def get_temp_filename(
|
||||||
def unique_list(elements: Iterable[T]) -> list[T]:
|
def unique_list(elements: Iterable[T]) -> list[T]:
|
||||||
"""Return a list with unique elements in the original order."""
|
"""Return a list with unique elements in the original order."""
|
||||||
return list(dict.fromkeys(elements))
|
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 abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Any, ClassVar, Mapping
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from beets import logging, util
|
from beets import logging, util
|
||||||
|
|
@ -37,6 +37,9 @@ from beets.util import (
|
||||||
syspath,
|
syspath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
PROXY_URL = "https://images.weserv.nl/"
|
PROXY_URL = "https://images.weserv.nl/"
|
||||||
|
|
||||||
log = logging.getLogger("beets")
|
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=[],
|
decorator_list=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# The ast.Module signature changed in 3.8 to accept a list of types to
|
|
||||||
# ignore.
|
|
||||||
mod = ast.Module([func_def], [])
|
mod = ast.Module([func_def], [])
|
||||||
|
|
||||||
ast.fix_missing_locations(mod)
|
ast.fix_missing_locations(mod)
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,9 @@ import os
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.
|
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 queue
|
||||||
import sys
|
import sys
|
||||||
from threading import Lock, Thread
|
from threading import Lock, Thread
|
||||||
from typing import Callable, Generator, TypeVar
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
from typing_extensions import TypeVarTuple, Unpack
|
from typing_extensions import TypeVarTuple, Unpack
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Generator
|
||||||
|
|
||||||
BUBBLE = "__PIPELINE_BUBBLE__"
|
BUBBLE = "__PIPELINE_BUBBLE__"
|
||||||
POISON = "__PIPELINE_POISON__"
|
POISON = "__PIPELINE_POISON__"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import (
|
from typing import TYPE_CHECKING, Literal, overload
|
||||||
TYPE_CHECKING,
|
|
||||||
Iterable,
|
|
||||||
Iterator,
|
|
||||||
Literal,
|
|
||||||
Sequence,
|
|
||||||
overload,
|
|
||||||
)
|
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
from requests_oauthlib import OAuth1Session
|
from requests_oauthlib import OAuth1Session
|
||||||
|
|
@ -42,6 +35,8 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||||
from beets.metadata_plugins import MetadataSourcePlugin
|
from beets.metadata_plugins import MetadataSourcePlugin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
|
|
||||||
from beets.importer import ImportSession
|
from beets.importer import ImportSession
|
||||||
from beets.library import Item
|
from beets.library import Item
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ class BaseServer:
|
||||||
if not self.ctrl_sock:
|
if not self.ctrl_sock:
|
||||||
self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port))
|
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):
|
def _send_event(self, event):
|
||||||
"""Notify subscribed connections of an event."""
|
"""Notify subscribed connections of an event."""
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,16 @@ import gi
|
||||||
|
|
||||||
from beets import ui
|
from beets import ui
|
||||||
|
|
||||||
|
try:
|
||||||
gi.require_version("Gst", "1.0")
|
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
|
from gi.repository import GLib, Gst # noqa: E402
|
||||||
|
|
||||||
Gst.init(None)
|
Gst.init(None)
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ autotagger. Requires the pyacoustid library.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
from functools import cached_property, partial
|
from functools import cached_property, partial
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import acoustid
|
import acoustid
|
||||||
import confuse
|
import confuse
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
|
||||||
return False
|
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
|
"""Determine whether the item should be transcoded as part of
|
||||||
conversion (i.e., its bitrate is high or it has the wrong format).
|
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 (
|
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
|
and item.format.lower() not in LOSSLESS_FORMATS
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
|
|
@ -236,6 +242,16 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
drive, relative paths pointing to media files
|
drive, relative paths pointing to media files
|
||||||
will be used.""",
|
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.parser.add_album_option()
|
||||||
cmd.func = self.convert_func
|
cmd.func = self.convert_func
|
||||||
return [cmd]
|
return [cmd]
|
||||||
|
|
@ -259,6 +275,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
link,
|
link,
|
||||||
playlist,
|
playlist,
|
||||||
|
force,
|
||||||
) = self._get_opts_and_config(empty_opts)
|
) = self._get_opts_and_config(empty_opts)
|
||||||
|
|
||||||
items = task.imported_items()
|
items = task.imported_items()
|
||||||
|
|
@ -272,6 +289,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
threads,
|
threads,
|
||||||
items,
|
items,
|
||||||
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Utilities converted from functions to methods on logging overhaul
|
# Utilities converted from functions to methods on logging overhaul
|
||||||
|
|
@ -347,6 +365,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
pretend=False,
|
pretend=False,
|
||||||
link=False,
|
link=False,
|
||||||
hardlink=False,
|
hardlink=False,
|
||||||
|
force=False,
|
||||||
):
|
):
|
||||||
"""A pipeline thread that converts `Item` objects from a
|
"""A pipeline thread that converts `Item` objects from a
|
||||||
library.
|
library.
|
||||||
|
|
@ -372,11 +391,11 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
if keep_new:
|
if keep_new:
|
||||||
original = dest
|
original = dest
|
||||||
converted = item.path
|
converted = item.path
|
||||||
if should_transcode(item, fmt):
|
if should_transcode(item, fmt, force):
|
||||||
converted = replace_ext(converted, ext)
|
converted = replace_ext(converted, ext)
|
||||||
else:
|
else:
|
||||||
original = item.path
|
original = item.path
|
||||||
if should_transcode(item, fmt):
|
if should_transcode(item, fmt, force):
|
||||||
dest = replace_ext(dest, ext)
|
dest = replace_ext(dest, ext)
|
||||||
converted = dest
|
converted = dest
|
||||||
|
|
||||||
|
|
@ -406,7 +425,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
)
|
)
|
||||||
util.move(item.path, original)
|
util.move(item.path, original)
|
||||||
|
|
||||||
if should_transcode(item, fmt):
|
if should_transcode(item, fmt, force):
|
||||||
linked = False
|
linked = False
|
||||||
try:
|
try:
|
||||||
self.encode(command, original, converted, pretend)
|
self.encode(command, original, converted, pretend)
|
||||||
|
|
@ -577,6 +596,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
link,
|
link,
|
||||||
playlist,
|
playlist,
|
||||||
|
force,
|
||||||
) = self._get_opts_and_config(opts)
|
) = self._get_opts_and_config(opts)
|
||||||
|
|
||||||
if opts.album:
|
if opts.album:
|
||||||
|
|
@ -613,6 +633,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
threads,
|
threads,
|
||||||
items,
|
items,
|
||||||
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
if playlist:
|
if playlist:
|
||||||
|
|
@ -735,7 +756,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
else:
|
else:
|
||||||
hardlink = self.config["hardlink"].get(bool)
|
hardlink = self.config["hardlink"].get(bool)
|
||||||
link = self.config["link"].get(bool)
|
link = self.config["link"].get(bool)
|
||||||
|
force = getattr(opts, "force", False)
|
||||||
return (
|
return (
|
||||||
dest,
|
dest,
|
||||||
threads,
|
threads,
|
||||||
|
|
@ -745,6 +766,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
link,
|
link,
|
||||||
playlist,
|
playlist,
|
||||||
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parallel_convert(
|
def _parallel_convert(
|
||||||
|
|
@ -758,13 +780,21 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
hardlink,
|
hardlink,
|
||||||
threads,
|
threads,
|
||||||
items,
|
items,
|
||||||
|
force,
|
||||||
):
|
):
|
||||||
"""Run the convert_item function for every items on as many thread as
|
"""Run the convert_item function for every items on as many thread as
|
||||||
defined in threads
|
defined in threads
|
||||||
"""
|
"""
|
||||||
convert = [
|
convert = [
|
||||||
self.convert_item(
|
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)
|
for _ in range(threads)
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Literal, Sequence
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
@ -32,6 +32,8 @@ from beets.metadata_plugins import (
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from beets.library import Item, Library
|
from beets.library import Item, Library
|
||||||
|
|
||||||
from ._typing import JSONDict
|
from ._typing import JSONDict
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import time
|
||||||
import traceback
|
import traceback
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from string import ascii_lowercase
|
from string import ascii_lowercase
|
||||||
from typing import TYPE_CHECKING, Sequence, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
from discogs_client import Client, Master, Release
|
from discogs_client import Client, Master, Release
|
||||||
|
|
@ -43,7 +43,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||||
from beets.metadata_plugins import MetadataSourcePlugin
|
from beets.metadata_plugins import MetadataSourcePlugin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
|
||||||
from beets.library import Item
|
from beets.library import Item
|
||||||
|
|
||||||
|
|
@ -132,9 +132,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
"user_token": "",
|
"user_token": "",
|
||||||
"separator": ", ",
|
"separator": ", ",
|
||||||
"index_tracks": False,
|
"index_tracks": False,
|
||||||
"featured_string": "Feat.",
|
|
||||||
"append_style_genre": False,
|
"append_style_genre": False,
|
||||||
"strip_disambiguation": True,
|
"strip_disambiguation": True,
|
||||||
|
"featured_string": "Feat.",
|
||||||
"anv": {
|
"anv": {
|
||||||
"artist_credit": True,
|
"artist_credit": True,
|
||||||
"artist": False,
|
"artist": False,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import yaml
|
||||||
from beets import plugins, ui, util
|
from beets import plugins, ui, util
|
||||||
from beets.dbcore import types
|
from beets.dbcore import types
|
||||||
from beets.importer import Action
|
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
|
# These "safe" types can avoid the format/parse cycle that most fields go
|
||||||
# through: they are safe to edit with native YAML types.
|
# 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):
|
def _edit_command(self, lib, opts, args):
|
||||||
"""The CLI command function for the `beet edit` command."""
|
"""The CLI command function for the `beet edit` command."""
|
||||||
# Get the objects to edit.
|
# 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
|
objs = albums if opts.album else items
|
||||||
if not objs:
|
if not objs:
|
||||||
ui.print_("Nothing to edit.")
|
ui.print_("Nothing to edit.")
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from collections import OrderedDict
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import cached_property
|
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 confuse
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -86,7 +86,7 @@ class Candidate:
|
||||||
path: None | bytes = None,
|
path: None | bytes = None,
|
||||||
url: None | str = None,
|
url: None | str = None,
|
||||||
match: None | MetadataMatch = None,
|
match: None | MetadataMatch = None,
|
||||||
size: None | Tuple[int, int] = None,
|
size: None | tuple[int, int] = None,
|
||||||
):
|
):
|
||||||
self._log = log
|
self._log = log
|
||||||
self.path = path
|
self.path = path
|
||||||
|
|
@ -682,7 +682,7 @@ class GoogleImages(RemoteArtSource):
|
||||||
"""
|
"""
|
||||||
if not (album.albumartist and album.album):
|
if not (album.albumartist and album.album):
|
||||||
return
|
return
|
||||||
search_string = f"{album.albumartist},{album.album}".encode("utf-8")
|
search_string = f"{album.albumartist},{album.album}".encode()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.request(
|
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.
|
# 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,
|
FileSystem,
|
||||||
CoverArtArchive,
|
CoverArtArchive,
|
||||||
ITunesStore,
|
ITunesStore,
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,17 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beets import plugins, ui
|
from beets import config, plugins, ui
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from beets.importer import ImportSession, ImportTask
|
from beets.importer import ImportSession, ImportTask
|
||||||
from beets.library import Item
|
from beets.library import Album, Item
|
||||||
|
|
||||||
|
|
||||||
def split_on_feat(
|
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]:
|
) -> tuple[str, str | None]:
|
||||||
"""Given an artist string, split the "main" artist from any artist
|
"""Given an artist string, split the "main" artist from any artist
|
||||||
on the right-hand side of a string like "feat". Return the main
|
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.
|
may be a string or None if none is present.
|
||||||
"""
|
"""
|
||||||
# split on the first "feat".
|
# 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))
|
parts = tuple(s.strip() for s in regex.split(artist, 1))
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
return parts[0], None
|
return parts[0], None
|
||||||
|
|
@ -44,18 +48,22 @@ def split_on_feat(
|
||||||
return parts
|
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."""
|
"""Determine whether the title contains a "featured" marker."""
|
||||||
return bool(
|
return bool(
|
||||||
re.search(
|
re.search(
|
||||||
plugins.feat_tokens(for_artist=False),
|
plugins.feat_tokens(for_artist=False, custom_words=custom_words),
|
||||||
title,
|
title,
|
||||||
flags=re.IGNORECASE,
|
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
|
"""Attempt to find featured artists in the item's artist fields and
|
||||||
return the results. Returns None if no featured artist found.
|
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.
|
# featured artist.
|
||||||
if albumartist_split[1] != "":
|
if albumartist_split[1] != "":
|
||||||
# Extract the featured artist from the right-hand side.
|
# 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
|
return feat_part
|
||||||
|
|
||||||
# Otherwise, if there's nothing on the right-hand side,
|
# Otherwise, if there's nothing on the right-hand side,
|
||||||
# look for a featuring artist on the left-hand side.
|
# look for a featuring artist on the left-hand side.
|
||||||
else:
|
else:
|
||||||
lhs, _ = split_on_feat(albumartist_split[0])
|
lhs, _ = split_on_feat(
|
||||||
|
albumartist_split[0], custom_words=custom_words
|
||||||
|
)
|
||||||
if lhs:
|
if lhs:
|
||||||
return lhs
|
return lhs
|
||||||
|
|
||||||
# Fall back to conservative handling of the track artist without relying
|
# Fall back to conservative handling of the track artist without relying
|
||||||
# on albumartist, which covers compilations using a 'Various Artists'
|
# on albumartist, which covers compilations using a 'Various Artists'
|
||||||
# albumartist and album tracks by a guest artist featuring a third artist.
|
# 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
|
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):
|
class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -96,6 +113,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
"drop": False,
|
"drop": False,
|
||||||
"format": "feat. {}",
|
"format": "feat. {}",
|
||||||
"keep_in_artist": False,
|
"keep_in_artist": False,
|
||||||
|
"preserve_album_artist": True,
|
||||||
|
"custom_words": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -115,15 +134,29 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
if self.config["auto"]:
|
if self.config["auto"]:
|
||||||
self.import_stages = [self.imported]
|
self.import_stages = [self.imported]
|
||||||
|
|
||||||
|
self.album_template_fields["album_artist_no_feat"] = (
|
||||||
|
_album_artist_no_feat
|
||||||
|
)
|
||||||
|
|
||||||
def commands(self) -> list[ui.Subcommand]:
|
def commands(self) -> list[ui.Subcommand]:
|
||||||
def func(lib, opts, args):
|
def func(lib, opts, args):
|
||||||
self.config.set_args(opts)
|
self.config.set_args(opts)
|
||||||
drop_feat = self.config["drop"].get(bool)
|
drop_feat = self.config["drop"].get(bool)
|
||||||
keep_in_artist_field = self.config["keep_in_artist"].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()
|
write = ui.should_write()
|
||||||
|
|
||||||
for item in lib.items(args):
|
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()
|
item.store()
|
||||||
if write:
|
if write:
|
||||||
item.try_write()
|
item.try_write()
|
||||||
|
|
@ -135,9 +168,17 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
"""Import hook for moving featuring artist automatically."""
|
"""Import hook for moving featuring artist automatically."""
|
||||||
drop_feat = self.config["drop"].get(bool)
|
drop_feat = self.config["drop"].get(bool)
|
||||||
keep_in_artist_field = self.config["keep_in_artist"].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():
|
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()
|
item.store()
|
||||||
|
|
||||||
def update_metadata(
|
def update_metadata(
|
||||||
|
|
@ -146,6 +187,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
feat_part: str,
|
feat_part: str,
|
||||||
drop_feat: bool,
|
drop_feat: bool,
|
||||||
keep_in_artist_field: bool,
|
keep_in_artist_field: bool,
|
||||||
|
custom_words: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Choose how to add new artists to the title and set the new
|
"""Choose how to add new artists to the title and set the new
|
||||||
metadata. Also, print out messages about any changes that are made.
|
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
|
"artist: {.artist} (Not changing due to keep_in_artist)", item
|
||||||
)
|
)
|
||||||
else:
|
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)
|
self._log.info("artist: {0.artist} -> {1}", item, track_artist)
|
||||||
item.artist = track_artist
|
item.artist = track_artist
|
||||||
|
|
||||||
if item.artist_sort:
|
if item.artist_sort:
|
||||||
# Just strip the featured artist from the sort name.
|
# 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
|
# Only update the title if it does not already contain a featured
|
||||||
# artist and if we do not drop featuring information.
|
# 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()
|
feat_format = self.config["format"].as_str()
|
||||||
new_format = feat_format.format(feat_part)
|
new_format = feat_format.format(feat_part)
|
||||||
new_title = f"{item.title} {new_format}"
|
new_title = f"{item.title} {new_format}"
|
||||||
|
|
@ -180,6 +226,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
item: Item,
|
item: Item,
|
||||||
drop_feat: bool,
|
drop_feat: bool,
|
||||||
keep_in_artist_field: bool,
|
keep_in_artist_field: bool,
|
||||||
|
preserve_album_artist: bool,
|
||||||
|
custom_words: list[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Look for featured artists in the item's artist fields and move
|
"""Look for featured artists in the item's artist fields and move
|
||||||
them to the title.
|
them to the title.
|
||||||
|
|
@ -193,22 +241,24 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||||
# Check whether there is a featured artist on this track and the
|
# Check whether there is a featured artist on this track and the
|
||||||
# artist field does not exactly match the album artist field. In
|
# artist field does not exactly match the album artist field. In
|
||||||
# that case, we attempt to move the featured artist to the title.
|
# 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
|
return False
|
||||||
|
|
||||||
_, featured = split_on_feat(artist)
|
_, featured = split_on_feat(artist, custom_words=custom_words)
|
||||||
if not featured:
|
if not featured:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._log.info("{.filepath}", item)
|
self._log.info("{.filepath}", item)
|
||||||
|
|
||||||
# Attempt to find the featured artist.
|
# 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:
|
if not feat_part:
|
||||||
self._log.info("no featuring artists found")
|
self._log.info("no featuring artists found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If we have a featuring artist, move it to the title.
|
# 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
|
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()
|
config["item_fields"].items(), config["pathfields"].items()
|
||||||
):
|
):
|
||||||
self._log.debug("adding item field {}", key)
|
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:
|
if func is not None:
|
||||||
self.template_fields[key] = func
|
self.template_fields[key] = func
|
||||||
|
|
||||||
# Album fields.
|
# Album fields.
|
||||||
for key, view in config["album_fields"].items():
|
for key, view in config["album_fields"].items():
|
||||||
self._log.debug("adding album field {}", key)
|
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:
|
if func is not None:
|
||||||
self.album_template_fields[key] = func
|
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
|
"""Given a Python expression or function body, compile it as a path
|
||||||
field function. The returned function takes a single argument, an
|
field function. The returned function takes a single argument, an
|
||||||
Item, and returns a Unicode string. If the expression cannot be
|
Item, and returns a Unicode string. If the expression cannot be
|
||||||
|
|
@ -97,7 +97,12 @@ class InlinePlugin(BeetsPlugin):
|
||||||
is_expr = True
|
is_expr = True
|
||||||
|
|
||||||
def _dict_for(obj):
|
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:
|
if album:
|
||||||
out["items"] = list(obj.items())
|
out["items"] = list(obj.items())
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,13 @@ The scraper script used is available here:
|
||||||
https://gist.github.com/1241307
|
https://gist.github.com/1241307
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
from functools import singledispatchmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -34,6 +37,9 @@ from beets import config, library, plugins, ui
|
||||||
from beets.library import Album, Item
|
from beets.library import Album, Item
|
||||||
from beets.util import plurality, unique_list
|
from beets.util import plurality, unique_list
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beets.library import LibModel
|
||||||
|
|
||||||
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
|
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
|
||||||
|
|
||||||
PYLAST_EXCEPTIONS = (
|
PYLAST_EXCEPTIONS = (
|
||||||
|
|
@ -100,7 +106,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
"separator": ", ",
|
"separator": ", ",
|
||||||
"prefer_specific": False,
|
"prefer_specific": False,
|
||||||
"title_case": True,
|
"title_case": True,
|
||||||
"extended_debug": False,
|
"pretend": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.setup()
|
self.setup()
|
||||||
|
|
@ -155,6 +161,11 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
flatten_tree(genres_tree, [], c14n_branches)
|
flatten_tree(genres_tree, [], c14n_branches)
|
||||||
return c14n_branches, canonicalize
|
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
|
@property
|
||||||
def sources(self) -> tuple[str, ...]:
|
def sources(self) -> tuple[str, ...]:
|
||||||
"""A tuple of allowed genre sources. May contain 'track',
|
"""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))
|
self._genre_cache[key] = self.fetch_genre(method(*args))
|
||||||
|
|
||||||
genre = self._genre_cache[key]
|
genre = self._genre_cache[key]
|
||||||
if self.config["extended_debug"]:
|
self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre)
|
||||||
self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre)
|
|
||||||
return genre
|
return genre
|
||||||
|
|
||||||
def fetch_album_genre(self, obj):
|
def fetch_album_genre(self, obj):
|
||||||
|
|
@ -321,7 +331,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
|
|
||||||
return self.config["separator"].as_str().join(formatted)
|
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
|
"""Return a list of genres for this Item or Album. Empty string genres
|
||||||
are removed."""
|
are removed."""
|
||||||
separator = self.config["separator"].get()
|
separator = self.config["separator"].get()
|
||||||
|
|
@ -342,9 +352,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
combined = old + new
|
combined = old + new
|
||||||
return self._resolve_genres(combined)
|
return self._resolve_genres(combined)
|
||||||
|
|
||||||
def _get_genre(
|
def _get_genre(self, obj: LibModel) -> tuple[str | None, ...]:
|
||||||
self, obj: Union[Album, Item]
|
|
||||||
) -> tuple[Union[str, None], ...]:
|
|
||||||
"""Get the final genre string for an Album or Item object.
|
"""Get the final genre string for an Album or Item object.
|
||||||
|
|
||||||
`self.sources` specifies allowed genre sources. Starting with the first
|
`self.sources` specifies allowed genre sources. Starting with the first
|
||||||
|
|
@ -459,6 +467,39 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
|
|
||||||
# Beets plugin hooks and CLI.
|
# 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):
|
def commands(self):
|
||||||
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
|
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
|
||||||
lastgenre_cmd.parser.add_option(
|
lastgenre_cmd.parser.add_option(
|
||||||
|
|
@ -516,111 +557,20 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
dest="album",
|
dest="album",
|
||||||
help="match albums instead of items (default)",
|
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)
|
lastgenre_cmd.parser.set_defaults(album=True)
|
||||||
|
|
||||||
def lastgenre_func(lib, opts, args):
|
def lastgenre_func(lib, opts, args):
|
||||||
write = ui.should_write()
|
|
||||||
pretend = getattr(opts, "pretend", False)
|
|
||||||
self.config.set_args(opts)
|
self.config.set_args(opts)
|
||||||
|
|
||||||
if opts.album:
|
method = lib.albums if opts.album else lib.items
|
||||||
# Fetch genres for whole albums
|
for obj in method(args):
|
||||||
for album in lib.albums(args):
|
self._process(obj, write=ui.should_write())
|
||||||
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()
|
|
||||||
|
|
||||||
lastgenre_cmd.func = lastgenre_func
|
lastgenre_cmd.func = lastgenre_func
|
||||||
return [lastgenre_cmd]
|
return [lastgenre_cmd]
|
||||||
|
|
||||||
def imported(self, session, task):
|
def imported(self, session, task):
|
||||||
"""Event hook called when an import task finishes."""
|
self._process(task.album if task.is_album else task.item, write=False)
|
||||||
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()
|
|
||||||
|
|
||||||
def _tags_for(self, obj, min_weight=None):
|
def _tags_for(self, obj, min_weight=None):
|
||||||
"""Core genre identification routine.
|
"""Core genre identification routine.
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ from html import unescape
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from pathlib import Path
|
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
|
from urllib.parse import quote, quote_plus, urlencode, urlparse
|
||||||
|
|
||||||
import langdetect
|
import langdetect
|
||||||
|
|
@ -42,6 +42,8 @@ from beets.autotag.distance import string_dist
|
||||||
from beets.util.config import sanitize_choices
|
from beets.util.config import sanitize_choices
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
|
||||||
from beets.importer import ImportTask
|
from beets.importer import ImportTask
|
||||||
from beets.library import Item, Library
|
from beets.library import Item, Library
|
||||||
from beets.logging import BeetsLogger as Logger
|
from beets.logging import BeetsLogger as Logger
|
||||||
|
|
@ -745,7 +747,9 @@ class Translator(RequestHandler):
|
||||||
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
|
TRANSLATE_URL = "https://api.cognitive.microsofttranslator.com/translate"
|
||||||
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
|
LINE_PARTS_RE = re.compile(r"^(\[\d\d:\d\d.\d\d\]|) *(.*)$")
|
||||||
SEPARATOR = " | "
|
SEPARATOR = " | "
|
||||||
remove_translations = partial(re.compile(r" / [^\n]+").sub, "")
|
remove_translations = staticmethod(
|
||||||
|
partial(re.compile(r" / [^\n]+").sub, "")
|
||||||
|
)
|
||||||
|
|
||||||
_log: Logger
|
_log: Logger
|
||||||
api_key: str
|
api_key: str
|
||||||
|
|
@ -956,7 +960,7 @@ class LyricsPlugin(RequestHandler, plugins.BeetsPlugin):
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def backends(self) -> list[Backend]:
|
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)
|
chosen = sanitize_choices(user_sources, self.BACKEND_BY_NAME)
|
||||||
if "google" in chosen and not self.config["google_API_key"].get():
|
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 import ui
|
||||||
from beets.autotag import Recommendation
|
from beets.autotag import Recommendation
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.ui.commands import PromptChoice
|
from beets.util import PromptChoice, displayable_path
|
||||||
from beets.util import displayable_path
|
|
||||||
from beetsplug.info import print_data
|
from beetsplug.info import print_data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from collections import Counter
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from typing import TYPE_CHECKING, Any, Iterable, Sequence
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
|
|
@ -31,9 +31,11 @@ import beets
|
||||||
import beets.autotag.hooks
|
import beets.autotag.hooks
|
||||||
from beets import config, plugins, util
|
from beets import config, plugins, util
|
||||||
from beets.metadata_plugins import MetadataSourcePlugin
|
from beets.metadata_plugins import MetadataSourcePlugin
|
||||||
|
from beets.util.deprecation import deprecate_for_user
|
||||||
from beets.util.id_extractors import extract_release_id
|
from beets.util.id_extractors import extract_release_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from beets.library import Item
|
from beets.library import Item
|
||||||
|
|
@ -89,6 +91,7 @@ RELEASE_INCLUDES = list(
|
||||||
"isrcs",
|
"isrcs",
|
||||||
"url-rels",
|
"url-rels",
|
||||||
"release-rels",
|
"release-rels",
|
||||||
|
"genres",
|
||||||
"tags",
|
"tags",
|
||||||
}
|
}
|
||||||
& set(musicbrainzngs.VALID_INCLUDES["release"])
|
& set(musicbrainzngs.VALID_INCLUDES["release"])
|
||||||
|
|
@ -118,13 +121,15 @@ BROWSE_CHUNKSIZE = 100
|
||||||
BROWSE_MAXTRACKS = 500
|
BROWSE_MAXTRACKS = 500
|
||||||
|
|
||||||
|
|
||||||
def _preferred_alias(aliases: list[JSONDict]):
|
def _preferred_alias(
|
||||||
"""Given an list of alias structures for an artist credit, select
|
aliases: list[JSONDict], languages: list[str] | None = None
|
||||||
and return the user's preferred alias alias or None if no matching
|
) -> 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.
|
alias is found.
|
||||||
"""
|
"""
|
||||||
if not aliases:
|
if not aliases:
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Only consider aliases that have locales set.
|
# Only consider aliases that have locales set.
|
||||||
valid_aliases = [a for a in aliases if "locale" in a]
|
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]
|
ignored_alias_types = [a.lower() for a in ignored_alias_types]
|
||||||
|
|
||||||
# Search configured locales in order.
|
# 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
|
# Find matching primary aliases for this locale that are not
|
||||||
# being ignored
|
# being ignored
|
||||||
matches = []
|
matches = []
|
||||||
|
|
@ -152,6 +160,8 @@ def _preferred_alias(aliases: list[JSONDict]):
|
||||||
|
|
||||||
return matches[0]
|
return matches[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _multi_artist_credit(
|
def _multi_artist_credit(
|
||||||
credit: list[JSONDict], include_join_phrase: bool
|
credit: list[JSONDict], include_join_phrase: bool
|
||||||
|
|
@ -323,7 +333,7 @@ def _find_actual_release_from_pseudo_release(
|
||||||
|
|
||||||
def _merge_pseudo_and_actual_album(
|
def _merge_pseudo_and_actual_album(
|
||||||
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
|
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.
|
Merges a pseudo release with its actual release.
|
||||||
|
|
||||||
|
|
@ -362,6 +372,10 @@ def _merge_pseudo_and_actual_album(
|
||||||
|
|
||||||
|
|
||||||
class MusicBrainzPlugin(MetadataSourcePlugin):
|
class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||||
|
@cached_property
|
||||||
|
def genres_field(self) -> str:
|
||||||
|
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Set up the python-musicbrainz-ngs module according to settings
|
"""Set up the python-musicbrainz-ngs module according to settings
|
||||||
from the beets configuration. This should be called at startup.
|
from the beets configuration. This should be called at startup.
|
||||||
|
|
@ -374,6 +388,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||||
"ratelimit": 1,
|
"ratelimit": 1,
|
||||||
"ratelimit_interval": 1,
|
"ratelimit_interval": 1,
|
||||||
"genres": False,
|
"genres": False,
|
||||||
|
"genres_tag": "genre",
|
||||||
"external_ids": {
|
"external_ids": {
|
||||||
"discogs": False,
|
"discogs": False,
|
||||||
"bandcamp": False,
|
"bandcamp": False,
|
||||||
|
|
@ -389,9 +404,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||||
self.config["search_limit"] = self.config["match"][
|
self.config["search_limit"] = self.config["match"][
|
||||||
"searchlimit"
|
"searchlimit"
|
||||||
].get()
|
].get()
|
||||||
self._log.warning(
|
deprecate_for_user(
|
||||||
"'musicbrainz.searchlimit' option is deprecated and will be "
|
self._log,
|
||||||
"removed in 3.0.0. Use 'musicbrainz.search_limit' instead."
|
"'musicbrainz.searchlimit' configuration option",
|
||||||
|
"'musicbrainz.search_limit'",
|
||||||
)
|
)
|
||||||
hostname = self.config["host"].as_str()
|
hostname = self.config["host"].as_str()
|
||||||
https = self.config["https"].get(bool)
|
https = self.config["https"].get(bool)
|
||||||
|
|
@ -715,8 +731,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
if self.config["genres"]:
|
if self.config["genres"]:
|
||||||
sources = [
|
sources = [
|
||||||
release["release-group"].get("tag-list", []),
|
release["release-group"].get(self.genres_field, []),
|
||||||
release.get("tag-list", []),
|
release.get(self.genres_field, []),
|
||||||
]
|
]
|
||||||
genres: Counter[str] = Counter()
|
genres: Counter[str] = Counter()
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,17 @@ from os.path import relpath
|
||||||
from beets import config, ui, util
|
from beets import config, ui, util
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.ui import Subcommand
|
from beets.ui import Subcommand
|
||||||
from beets.ui.commands import PromptChoice
|
from beets.util import PromptChoice, get_temp_filename
|
||||||
from beets.util import get_temp_filename
|
|
||||||
|
|
||||||
# Indicate where arguments should be inserted into the command string.
|
# Indicate where arguments should be inserted into the command string.
|
||||||
# If this is missing, they're placed at the end.
|
# If this is missing, they're placed at the end.
|
||||||
ARGS_MARKER = "$args"
|
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(
|
def play(
|
||||||
command_str,
|
command_str,
|
||||||
|
|
@ -132,8 +136,23 @@ class PlayPlugin(BeetsPlugin):
|
||||||
return
|
return
|
||||||
|
|
||||||
open_args = self._playlist_or_paths(paths)
|
open_args = self._playlist_or_paths(paths)
|
||||||
|
open_args_str = [
|
||||||
|
p.decode("utf-8") for p in self._playlist_or_paths(paths)
|
||||||
|
]
|
||||||
command_str = self._command_str(opts.args)
|
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,
|
# Check if the selection exceeds configured threshold. If True,
|
||||||
# cancel, otherwise proceed with play command.
|
# cancel, otherwise proceed with play command.
|
||||||
if opts.yes or not self._exceeds_threshold(
|
if opts.yes or not self._exceeds_threshold(
|
||||||
|
|
@ -162,6 +181,7 @@ class PlayPlugin(BeetsPlugin):
|
||||||
return paths
|
return paths
|
||||||
else:
|
else:
|
||||||
return [self._create_tmp_playlist(paths)]
|
return [self._create_tmp_playlist(paths)]
|
||||||
|
return [shlex.quote(self._create_tmp_playlist(paths))]
|
||||||
|
|
||||||
def _exceeds_threshold(
|
def _exceeds_threshold(
|
||||||
self, selection, command_str, open_args, item_type="track"
|
self, selection, command_str, open_args, item_type="track"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing.pool import ThreadPool
|
from multiprocessing.pool import ThreadPool
|
||||||
from threading import Event, Thread
|
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 import ui
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
|
|
@ -36,7 +36,7 @@ from beets.util import command_output, displayable_path, syspath
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import optparse
|
import optparse
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
|
|
||||||
from confuse import ConfigView
|
from confuse import ConfigView
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@
|
||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""Adds Spotify release and track search support to the autotagger, along with
|
"""Adds Spotify release and track search support to the autotagger.
|
||||||
Spotify playlist construction.
|
|
||||||
|
Also includes Spotify playlist construction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -23,9 +24,10 @@ import base64
|
||||||
import collections
|
import collections
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
|
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -41,6 +43,8 @@ from beets.metadata_plugins import (
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from beets.library import Library
|
from beets.library import Library
|
||||||
from beetsplug._typing import JSONDict
|
from beetsplug._typing import JSONDict
|
||||||
|
|
||||||
|
|
@ -50,13 +54,14 @@ DEFAULT_WAITING_TIME = 5
|
||||||
class SearchResponseAlbums(IDResponse):
|
class SearchResponseAlbums(IDResponse):
|
||||||
"""A response returned by the Spotify API.
|
"""A response returned by the Spotify API.
|
||||||
|
|
||||||
We only use items and disregard the pagination information.
|
We only use items and disregard the pagination information. i.e.
|
||||||
i.e. res["albums"]["items"][0].
|
res["albums"]["items"][0].
|
||||||
|
|
||||||
There are more fields in the response, but we only type
|
There are more fields in the response, but we only type the ones we
|
||||||
the ones we currently use.
|
currently use.
|
||||||
|
|
||||||
see https://developer.spotify.com/documentation/web-api/reference/search
|
see https://developer.spotify.com/documentation/web-api/reference/search
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
album_type: str
|
album_type: str
|
||||||
|
|
@ -77,6 +82,12 @@ class APIError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioFeaturesUnavailableError(Exception):
|
||||||
|
"""Raised when audio features API returns 403 (deprecated)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SpotifyPlugin(
|
class SpotifyPlugin(
|
||||||
SearchApiMetadataSourcePlugin[
|
SearchApiMetadataSourcePlugin[
|
||||||
Union[SearchResponseAlbums, SearchResponseTracks]
|
Union[SearchResponseAlbums, SearchResponseTracks]
|
||||||
|
|
@ -140,6 +151,12 @@ class SpotifyPlugin(
|
||||||
self.config["client_id"].redact = True
|
self.config["client_id"].redact = True
|
||||||
self.config["client_secret"].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()
|
self.setup()
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
|
|
@ -158,9 +175,7 @@ class SpotifyPlugin(
|
||||||
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
||||||
|
|
||||||
def _authenticate(self) -> None:
|
def _authenticate(self) -> None:
|
||||||
"""Request an access token via the Client Credentials Flow:
|
"""Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#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_id: str = self.config["client_id"].as_str()
|
||||||
c_secret: str = self.config["client_secret"].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 method: HTTP method to use for the request.
|
||||||
:param url: URL for the new :class:`Request` object.
|
: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`.
|
in the query string for the :class:`Request`.
|
||||||
:type params: dict
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if retry_count > max_retries:
|
if retry_count > max_retries:
|
||||||
|
|
@ -246,6 +261,17 @@ class SpotifyPlugin(
|
||||||
f"API Error: {e.response.status_code}\n"
|
f"API Error: {e.response.status_code}\n"
|
||||||
f"URL: {url}\nparams: {params}"
|
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:
|
elif e.response.status_code == 429:
|
||||||
seconds = e.response.headers.get(
|
seconds = e.response.headers.get(
|
||||||
"Retry-After", DEFAULT_WAITING_TIME
|
"Retry-After", DEFAULT_WAITING_TIME
|
||||||
|
|
@ -268,7 +294,8 @@ class SpotifyPlugin(
|
||||||
raise APIError("Bad Gateway.")
|
raise APIError("Bad Gateway.")
|
||||||
elif e.response is not None:
|
elif e.response is not None:
|
||||||
raise APIError(
|
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}"
|
f"URL:\n{url}\nparams:\n{params}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -279,10 +306,11 @@ class SpotifyPlugin(
|
||||||
"""Fetch an album by its Spotify ID or URL and return an
|
"""Fetch an album by its Spotify ID or URL and return an
|
||||||
AlbumInfo object or None if the album is not found.
|
AlbumInfo object or None if the album is not found.
|
||||||
|
|
||||||
:param album_id: Spotify ID or URL for the album
|
:param str album_id: Spotify ID or URL for the album
|
||||||
:type album_id: str
|
|
||||||
:return: AlbumInfo object for album
|
:returns: AlbumInfo object for album
|
||||||
:rtype: beets.autotag.hooks.AlbumInfo or None
|
:rtype: beets.autotag.hooks.AlbumInfo or None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not (spotify_id := self._extract_id(album_id)):
|
if not (spotify_id := self._extract_id(album_id)):
|
||||||
return None
|
return None
|
||||||
|
|
@ -356,7 +384,9 @@ class SpotifyPlugin(
|
||||||
|
|
||||||
:param track_data: Simplified track object
|
:param track_data: Simplified track object
|
||||||
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
|
(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"])
|
artist, artist_id = self.get_artist(track_data["artists"])
|
||||||
|
|
||||||
|
|
@ -385,6 +415,7 @@ class SpotifyPlugin(
|
||||||
"""Fetch a track by its Spotify ID or URL.
|
"""Fetch a track by its Spotify ID or URL.
|
||||||
|
|
||||||
Returns a TrackInfo object or None if the track is not found.
|
Returns a TrackInfo object or None if the track is not found.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not (spotify_id := self._extract_id(track_id)):
|
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``,
|
"""Query the Spotify Search API for the specified ``query_string``,
|
||||||
applying the provided ``filters``.
|
applying the provided ``filters``.
|
||||||
|
|
||||||
:param query_type: Item type to search across. Valid types are:
|
:param query_type: Item type to search across. Valid types are: 'album',
|
||||||
'album', 'artist', 'playlist', and 'track'.
|
'artist', 'playlist', and 'track'.
|
||||||
:param filters: Field filters to apply.
|
:param filters: Field filters to apply.
|
||||||
:param query_string: Additional query to include in the search.
|
:param query_string: Additional query to include in the search.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
query = self._construct_search_query(
|
query = self._construct_search_query(
|
||||||
filters=filters, query_string=query_string
|
filters=filters, query_string=query_string
|
||||||
|
|
@ -523,13 +555,16 @@ class SpotifyPlugin(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _match_library_tracks(self, library: Library, keywords: str):
|
def _match_library_tracks(self, library: Library, keywords: str):
|
||||||
"""Get a list of simplified track object dicts for library tracks
|
"""Get simplified track object dicts for library tracks.
|
||||||
matching the specified ``keywords``.
|
|
||||||
|
Matches tracks based on the specified ``keywords``.
|
||||||
|
|
||||||
:param library: beets library object to query.
|
:param library: beets library object to query.
|
||||||
:param keywords: Query to match library items against.
|
: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 = []
|
results = []
|
||||||
failures = []
|
failures = []
|
||||||
|
|
@ -640,12 +675,14 @@ class SpotifyPlugin(
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _output_match_results(self, results):
|
def _output_match_results(self, results):
|
||||||
"""Open a playlist or print Spotify URLs for the provided track
|
"""Open a playlist or print Spotify URLs.
|
||||||
object dicts.
|
|
||||||
|
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:
|
if results:
|
||||||
spotify_ids = [track_data["id"] for track_data in results]
|
spotify_ids = [track_data["id"] for track_data in results]
|
||||||
|
|
@ -691,6 +728,8 @@ class SpotifyPlugin(
|
||||||
item["isrc"] = isrc
|
item["isrc"] = isrc
|
||||||
item["ean"] = ean
|
item["ean"] = ean
|
||||||
item["upc"] = upc
|
item["upc"] = upc
|
||||||
|
|
||||||
|
if self.audio_features_available:
|
||||||
audio_features = self.track_audio_features(spotify_track_id)
|
audio_features = self.track_audio_features(spotify_track_id)
|
||||||
if audio_features is None:
|
if audio_features is None:
|
||||||
self._log.info("No audio features found for: {}", item)
|
self._log.info("No audio features found for: {}", item)
|
||||||
|
|
@ -698,6 +737,9 @@ class SpotifyPlugin(
|
||||||
for feature, value in audio_features.items():
|
for feature, value in audio_features.items():
|
||||||
if feature in self.spotify_audio_features:
|
if feature in self.spotify_audio_features:
|
||||||
item[self.spotify_audio_features[feature]] = value
|
item[self.spotify_audio_features[feature]] = value
|
||||||
|
else:
|
||||||
|
self._log.debug("Audio features API unavailable, skipping")
|
||||||
|
|
||||||
item["spotify_updated"] = time.time()
|
item["spotify_updated"] = time.time()
|
||||||
item.store()
|
item.store()
|
||||||
if write:
|
if write:
|
||||||
|
|
@ -721,11 +763,34 @@ class SpotifyPlugin(
|
||||||
)
|
)
|
||||||
|
|
||||||
def track_audio_features(self, track_id: str):
|
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:
|
try:
|
||||||
return self._handle_response(
|
return self._handle_response(
|
||||||
"get", f"{self.audio_features_url}{track_id}"
|
"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:
|
except APIError as e:
|
||||||
self._log.debug("Spotify API error: {}", e)
|
self._log.debug("Spotify API error: {}", e)
|
||||||
return None
|
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 base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import typing as t
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from flask import g, jsonify
|
from flask import jsonify
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
from werkzeug.routing import BaseConverter, PathConverter
|
from werkzeug.routing import BaseConverter, PathConverter
|
||||||
|
|
||||||
|
|
@ -28,6 +29,17 @@ from beets import ui, util
|
||||||
from beets.dbcore.query import PathQuery
|
from beets.dbcore.query import PathQuery
|
||||||
from beets.plugins import BeetsPlugin
|
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.
|
# Utilities.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -232,7 +244,7 @@ def _get_unique_table_field_values(model, field, sort_field):
|
||||||
raise KeyError
|
raise KeyError
|
||||||
with g.lib.transaction() as tx:
|
with g.lib.transaction() as tx:
|
||||||
rows = tx.query(
|
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]
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,11 @@ var AppView = Backbone.View.extend({
|
||||||
'pause': _.bind(this.audioPause, this),
|
'pause': _.bind(this.audioPause, this),
|
||||||
'ended': _.bind(this.audioEnded, this)
|
'ended': _.bind(this.audioEnded, this)
|
||||||
});
|
});
|
||||||
|
if ("mediaSession" in navigator) {
|
||||||
|
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
||||||
|
this.playNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
showItems: function(items) {
|
showItems: function(items) {
|
||||||
this.shownItems = items;
|
this.shownItems = items;
|
||||||
|
|
@ -306,7 +311,9 @@ var AppView = Backbone.View.extend({
|
||||||
},
|
},
|
||||||
audioEnded: function() {
|
audioEnded: function() {
|
||||||
this.playingItem.entryView.setPlaying(false);
|
this.playingItem.entryView.setPlaying(false);
|
||||||
|
this.playNext();
|
||||||
|
},
|
||||||
|
playNext: function(){
|
||||||
// Try to play the next track.
|
// Try to play the next track.
|
||||||
var idx = this.shownItems.indexOf(this.playingItem);
|
var idx = this.shownItems.indexOf(this.playingItem);
|
||||||
if (idx == -1) {
|
if (idx == -1) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin):
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"keep_fields": [],
|
"keep_fields": [],
|
||||||
"update_database": False,
|
"update_database": False,
|
||||||
|
"omit_single_disc": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -123,9 +124,14 @@ class ZeroPlugin(BeetsPlugin):
|
||||||
"""
|
"""
|
||||||
fields_set = False
|
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:
|
if not self.fields_to_progs:
|
||||||
self._log.warning("no fields, nothing to do")
|
self._log.warning("no fields list to remove")
|
||||||
return False
|
|
||||||
|
|
||||||
for field, progs in self.fields_to_progs.items():
|
for field, progs in self.fields_to_progs.items():
|
||||||
if field in tags:
|
if field in tags:
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,115 @@ below!
|
||||||
Unreleased
|
Unreleased
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
Beets now requires Python 3.10 or later since support for EOL Python 3.9 has
|
||||||
|
been dropped.
|
||||||
|
|
||||||
New features:
|
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:
|
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:
|
For packagers:
|
||||||
|
|
||||||
|
- The minimum supported Python version is now 3.10.
|
||||||
|
|
||||||
Other changes:
|
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)
|
2.5.0 (October 11, 2025)
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
|
|
@ -24,16 +125,18 @@ New features:
|
||||||
without storing or writing them.
|
without storing or writing them.
|
||||||
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
|
- :doc:`plugins/convert`: Add a config option to disable writing metadata to
|
||||||
converted files.
|
converted files.
|
||||||
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
|
- :doc:`plugins/discogs`: New config option
|
||||||
stripping discogs numeric disambiguation on artist and label fields.
|
: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` Added support for featured artists. :bug:`6038`
|
||||||
- :doc:`plugins/discogs` New configuration option `featured_string` to change
|
- :doc:`plugins/discogs` New configuration option
|
||||||
the default string used to join featured artists. The default string is
|
:conf:`plugins.discogs:featured_string` to change the default string used to
|
||||||
`Feat.`.
|
join featured artists. The default string is `Feat.`.
|
||||||
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
|
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
|
||||||
:bug:`3354`
|
:bug:`3354`
|
||||||
- :doc:`plugins/discogs` Support for name variations and config options to
|
- :doc:`plugins/discogs` Support for name variations and config options to
|
||||||
specify where the variations are written. :bug:`3354`
|
specify where the variations are written. :bug:`3354`
|
||||||
|
- :doc:`plugins/web` Support for `nexttrack` keyboard press
|
||||||
|
|
||||||
Bug fixes:
|
Bug fixes:
|
||||||
|
|
||||||
|
|
@ -53,15 +156,14 @@ Bug fixes:
|
||||||
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
|
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
|
||||||
artists but not labels. :bug:`5366`
|
artists but not labels. :bug:`5366`
|
||||||
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
|
- :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
|
- :doc:`/plugins/fromfilename`: Fix :bug:`5218`, improve the code (refactor
|
||||||
regexps, allow for more cases, add some logging), add tests.
|
regexps, allow for more cases, add some logging), add tests.
|
||||||
- Metadata source plugins: Fixed data source penalty calculation that was
|
- Metadata source plugins: Fixed data source penalty calculation that was
|
||||||
incorrectly applied during import matching. The ``source_weight``
|
incorrectly applied during import matching. The
|
||||||
configuration option has been renamed to ``data_source_mismatch_penalty`` to
|
:conf:`plugins.index:source_weight` configuration option has been renamed to
|
||||||
better reflect its purpose. :bug:`6066`
|
:conf:`plugins.index:data_source_mismatch_penalty` to better reflect its
|
||||||
|
purpose. :bug:`6066`
|
||||||
For packagers:
|
|
||||||
|
|
||||||
Other changes:
|
Other changes:
|
||||||
|
|
||||||
|
|
@ -107,12 +209,13 @@ New features:
|
||||||
separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``,
|
separate plugin. The default :ref:`plugins-config` includes ``musicbrainz``,
|
||||||
but if you've customized your ``plugins`` list in your configuration, you'll
|
but if you've customized your ``plugins`` list in your configuration, you'll
|
||||||
need to explicitly add ``musicbrainz`` to continue using this functionality.
|
need to explicitly add ``musicbrainz`` to continue using this functionality.
|
||||||
Configuration option ``musicbrainz.enabled`` has thus been deprecated.
|
Configuration option :conf:`plugins.musicbrainz:enabled` has thus been
|
||||||
:bug:`2686` :bug:`4605`
|
deprecated. :bug:`2686` :bug:`4605`
|
||||||
- :doc:`plugins/web`: Show notifications when a track plays. This uses the Media
|
- :doc:`plugins/web`: Show notifications when a track plays. This uses the Media
|
||||||
Session API to customize media notifications.
|
Session API to customize media notifications.
|
||||||
- :doc:`plugins/discogs`: Add configurable ``search_limit`` option to limit the
|
- :doc:`plugins/discogs`: Add configurable :conf:`plugins.discogs:search_limit`
|
||||||
number of results returned by the Discogs metadata search queries.
|
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
|
- :doc:`plugins/discogs`: Implement ``track_for_id`` method to allow retrieving
|
||||||
singletons by their Discogs ID. :bug:`4661`
|
singletons by their Discogs ID. :bug:`4661`
|
||||||
- :doc:`plugins/replace`: Add new plugin.
|
- :doc:`plugins/replace`: Add new plugin.
|
||||||
|
|
@ -127,12 +230,13 @@ New features:
|
||||||
be played for it to be counted as played instead of skipped.
|
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/web`: Display artist and album as part of the search results.
|
||||||
- :doc:`plugins/spotify` :doc:`plugins/deezer`: Add new configuration option
|
- :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:
|
Bug fixes:
|
||||||
|
|
||||||
- :doc:`plugins/musicbrainz`: fix regression where user configured
|
- :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:/``.
|
- tests: Fix library tests failing on Windows when run from outside ``D:/``.
|
||||||
:bug:`5802`
|
:bug:`5802`
|
||||||
- Fix an issue where calling ``Library.add`` would cause the ``database_change``
|
- Fix an issue where calling ``Library.add`` would cause the ``database_change``
|
||||||
|
|
@ -164,9 +268,10 @@ Bug fixes:
|
||||||
|
|
||||||
For packagers:
|
For packagers:
|
||||||
|
|
||||||
- Optional ``extra_tags`` parameter has been removed from
|
- Optional :conf:`plugins.musicbrainz:extra_tags` parameter has been removed
|
||||||
``BeetsPlugin.candidates`` method signature since it is never passed in. If
|
from ``BeetsPlugin.candidates`` method signature since it is never passed in.
|
||||||
you override this method in your plugin, feel free to remove this parameter.
|
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
|
- Loosened ``typing_extensions`` dependency in pyproject.toml to apply to every
|
||||||
python version.
|
python version.
|
||||||
|
|
||||||
|
|
@ -177,8 +282,8 @@ For plugin developers:
|
||||||
art sources might need to be adapted.
|
art sources might need to be adapted.
|
||||||
- We split the responsibilities of plugins into two base classes
|
- We split the responsibilities of plugins into two base classes
|
||||||
|
|
||||||
1. :class:`beets.plugins.BeetsPlugin` is the base class for all plugins, any
|
1. |BeetsPlugin| is the base class for all plugins, any plugin needs to
|
||||||
plugin needs to inherit from this class.
|
inherit from this class.
|
||||||
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
|
2. :class:`beets.metadata_plugin.MetadataSourcePlugin` allows plugins to act
|
||||||
like metadata sources. E.g. used by the MusicBrainz plugin. All plugins in
|
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
|
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
|
``beet list -a title:something`` or ``beet list artpath:cover``. Consequently
|
||||||
album queries involving ``path`` field have been sped up, like ``beet list -a
|
album queries involving ``path`` field have been sped up, like ``beet list -a
|
||||||
path:/path/``.
|
path:/path/``.
|
||||||
|
- :doc:`plugins/importsource`: Added plugin
|
||||||
- :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which
|
- :doc:`plugins/ftintitle`: New ``keep_in_artist`` option for the plugin, which
|
||||||
allows keeping the "feat." part in the artist metadata while still changing
|
allows keeping the "feat." part in the artist metadata while still changing
|
||||||
the title.
|
the title.
|
||||||
|
|
@ -522,8 +628,9 @@ New features:
|
||||||
:bug:`4348`
|
:bug:`4348`
|
||||||
- Create the parental directories for database if they do not exist. :bug:`3808`
|
- Create the parental directories for database if they do not exist. :bug:`3808`
|
||||||
:bug:`4327`
|
:bug:`4327`
|
||||||
- :ref:`musicbrainz-config`: a new :ref:`musicbrainz.enabled` option allows
|
- :ref:`musicbrainz-config`: a new :conf:`plugins.musicbrainz:enabled` option
|
||||||
disabling the MusicBrainz metadata source during the autotagging process
|
allows disabling the MusicBrainz metadata source during the autotagging
|
||||||
|
process
|
||||||
- :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101`
|
- :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101`
|
||||||
- Add the item fields ``bitrate_mode``, ``encoder_info`` and
|
- Add the item fields ``bitrate_mode``, ``encoder_info`` and
|
||||||
``encoder_settings``.
|
``encoder_settings``.
|
||||||
|
|
@ -556,8 +663,8 @@ New features:
|
||||||
:bug:`4561` :bug:`4600`
|
:bug:`4561` :bug:`4600`
|
||||||
- :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
|
- :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
|
||||||
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
|
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
|
||||||
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
|
enabled via the :conf:`plugins.musicbrainz:external_ids` options, release ID's
|
||||||
extracted from those URL's and imported to the library. :bug:`4220`
|
will be extracted from those URL's and imported to the library. :bug:`4220`
|
||||||
- :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
|
- :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
|
||||||
with converted media files. :bug:`4373`
|
with converted media files. :bug:`4373`
|
||||||
- Fetch the ``release_group_title`` field from MusicBrainz. :bug:`4809`
|
- 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,
|
- ``beet remove`` now also allows interactive selection of items from the query,
|
||||||
similar to ``beet modify``.
|
similar to ``beet modify``.
|
||||||
- Enable HTTPS for MusicBrainz by default and add configuration option ``https``
|
- Enable HTTPS for MusicBrainz by default and add configuration option
|
||||||
for custom servers. See :ref:`musicbrainz-config` for more details.
|
: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
|
- :doc:`/plugins/mpdstats`: Add a new ``strip_path`` option to help build the
|
||||||
right local path from MPD information.
|
right local path from MPD information.
|
||||||
- :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
|
- :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on
|
||||||
|
|
@ -932,8 +1040,8 @@ Other new things:
|
||||||
server.
|
server.
|
||||||
- :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
|
- :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between
|
||||||
token- and password-based authentication based on the server version.
|
token- and password-based authentication based on the server version.
|
||||||
- A new :ref:`extra_tags` configuration option lets you use more metadata in
|
- A new :conf:`plugins.musicbrainz:extra_tags` configuration option lets you use
|
||||||
MusicBrainz queries to further narrow the search.
|
more metadata in MusicBrainz queries to further narrow the search.
|
||||||
- A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
|
- A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets.
|
||||||
- :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
|
- :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
|
||||||
option that controls the quality of the image output when the image is
|
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
|
(and now deprecated) separate ``host``, ``port``, and ``contextpath`` config
|
||||||
options. As a consequence, the plugin can now talk to Subsonic over HTTPS.
|
options. As a consequence, the plugin can now talk to Subsonic over HTTPS.
|
||||||
Thanks to :user:`jef`. :bug:`3449`
|
Thanks to :user:`jef`. :bug:`3449`
|
||||||
- :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation
|
- :doc:`/plugins/discogs`: The new :conf:`plugins.discogs:index_tracks` option
|
||||||
of work names and intra-work divisions into imported track titles. Thanks to
|
enables incorporation of work names and intra-work divisions into imported
|
||||||
:user:`cole-miller`. :bug:`3459`
|
track titles. Thanks to :user:`cole-miller`. :bug:`3459`
|
||||||
- :doc:`/plugins/web`: The query API now interprets backslashes as path
|
- :doc:`/plugins/web`: The query API now interprets backslashes as path
|
||||||
separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567`
|
separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567`
|
||||||
- ``beet import`` now handles tar archives with bzip2 or gzip compression.
|
- ``beet import`` now handles tar archives with bzip2 or gzip compression.
|
||||||
|
|
@ -1003,9 +1111,9 @@ Other new things:
|
||||||
:user:`logan-arens`. :bug:`2947`
|
:user:`logan-arens`. :bug:`2947`
|
||||||
- There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins
|
- There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins
|
||||||
to load.
|
to load.
|
||||||
- A new :ref:`genres` option fetches genre information from MusicBrainz. This
|
- A new :conf:`plugins.musicbrainz:genres` option fetches genre information from
|
||||||
functionality depends on functionality that is currently unreleased in the
|
MusicBrainz. This functionality depends on functionality that is currently
|
||||||
python-musicbrainzngs_ library: see PR `#266
|
unreleased in the python-musicbrainzngs_ library: see PR `#266
|
||||||
<https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to
|
<https://github.com/alastair/python-musicbrainzngs/pull/266>`_. Thanks to
|
||||||
:user:`aereaux`.
|
:user:`aereaux`.
|
||||||
- :doc:`/plugins/replaygain`: Analysis now happens in parallel using the
|
- :doc:`/plugins/replaygain`: Analysis now happens in parallel using the
|
||||||
|
|
@ -1045,9 +1153,10 @@ Fixes:
|
||||||
:bug:`3867`
|
:bug:`3867`
|
||||||
- :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be
|
- :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`
|
redacted even when ``include_paths`` option is set. :bug:`3866`
|
||||||
- :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that
|
- :doc:`/plugins/discogs`: Fixed a bug with the
|
||||||
sometimes caused the index to be discarded. Also, remove the extra semicolon
|
:conf:`plugins.discogs:index_tracks` option that sometimes caused the index to
|
||||||
that was added when there is no index track.
|
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
|
- :doc:`/plugins/subsonicupdate`: The API client was using the ``POST`` method
|
||||||
rather the ``GET`` method. Also includes better exception handling, response
|
rather the ``GET`` method. Also includes better exception handling, response
|
||||||
parsing, and tests.
|
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
|
- Fix a regression in the last release that made the image resizer fail to
|
||||||
detect older versions of ImageMagick. :bug:`3269`
|
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`
|
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`
|
``gmusicapi`` module. :bug:`3270`
|
||||||
- Fix an incompatibility with Python 3.8's AST changes. :bug:`3278`
|
- 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`
|
singletons. :bug:`3220` :bug:`3219`
|
||||||
- :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues
|
- :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`
|
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
|
- Add a new option to automatically upload to Google Play Music library on
|
||||||
track import. Thanks to :user:`shuaiscott`.
|
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
|
- :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
|
``added:-1w..`` to match music added in the last week, for example. Thanks to
|
||||||
:user:`euri10`. :bug:`2598`
|
: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`
|
library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586`
|
||||||
- :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from
|
- :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
|
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`
|
analysis tool. Thanks to :user:`jmwatte`. :bug:`1343`
|
||||||
- A new ``filesize`` field on items indicates the number of bytes in the file.
|
- A new ``filesize`` field on items indicates the number of bytes in the file.
|
||||||
:bug:`1291`
|
:bug:`1291`
|
||||||
- A new :ref:`search_limit` configuration option allows you to specify how many
|
- A new :conf:`plugins.index:search_limit` configuration option allows you to
|
||||||
search results you wish to see when looking up releases at MusicBrainz during
|
specify how many search results you wish to see when looking up releases at
|
||||||
import. :bug:`1245`
|
MusicBrainz during import. :bug:`1245`
|
||||||
- The importer now records the data source for a match in a new flexible
|
- The importer now records the data source for a match in a new flexible
|
||||||
attribute ``data_source`` on items and albums. :bug:`1311`
|
attribute ``data_source`` on items and albums. :bug:`1311`
|
||||||
- The colors used in the terminal interface are now configurable via the new
|
- 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
|
list of plugin names) and ``pluginpath`` (a colon-separated list of
|
||||||
directories to search beyond ``sys.path``). Plugins are just Python modules
|
directories to search beyond ``sys.path``). Plugins are just Python modules
|
||||||
under the ``beetsplug`` namespace package containing subclasses of
|
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.
|
:doc:`/plugins/index` for instructions.
|
||||||
- As a consequence of adding album art, the database was significantly
|
- As a consequence of adding album art, the database was significantly
|
||||||
refactored to keep track of some information at an album (rather than item)
|
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 -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add custom extensions directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "extensions"))
|
||||||
|
|
||||||
project = "beets"
|
project = "beets"
|
||||||
AUTHOR = "Adrian Sampson"
|
AUTHOR = "Adrian Sampson"
|
||||||
|
|
@ -14,7 +19,7 @@ copyright = "2016, Adrian Sampson"
|
||||||
master_doc = "index"
|
master_doc = "index"
|
||||||
language = "en"
|
language = "en"
|
||||||
version = "2.5"
|
version = "2.5"
|
||||||
release = "2.5.0"
|
release = "2.5.1"
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
@ -23,13 +28,17 @@ extensions = [
|
||||||
"sphinx.ext.autodoc",
|
"sphinx.ext.autodoc",
|
||||||
"sphinx.ext.autosummary",
|
"sphinx.ext.autosummary",
|
||||||
"sphinx.ext.extlinks",
|
"sphinx.ext.extlinks",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
"sphinx_design",
|
||||||
|
"sphinx_copybutton",
|
||||||
|
"conf",
|
||||||
]
|
]
|
||||||
|
|
||||||
autosummary_generate = True
|
autosummary_generate = True
|
||||||
exclude_patterns = ["_build"]
|
exclude_patterns = ["_build"]
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
|
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
|
||||||
|
|
||||||
|
|
||||||
pygments_style = "sphinx"
|
pygments_style = "sphinx"
|
||||||
|
|
||||||
# External links to the bug tracker and other sites.
|
# External links to the bug tracker and other sites.
|
||||||
|
|
@ -79,6 +88,7 @@ man_pages = [
|
||||||
rst_epilog = """
|
rst_epilog = """
|
||||||
.. |Album| replace:: :class:`~beets.library.models.Album`
|
.. |Album| replace:: :class:`~beets.library.models.Album`
|
||||||
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
|
.. |AlbumInfo| replace:: :class:`beets.autotag.hooks.AlbumInfo`
|
||||||
|
.. |BeetsPlugin| replace:: :class:`beets.plugins.BeetsPlugin`
|
||||||
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
|
.. |ImportSession| replace:: :class:`~beets.importer.session.ImportSession`
|
||||||
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
|
.. |ImportTask| replace:: :class:`~beets.importer.tasks.ImportTask`
|
||||||
.. |Item| replace:: :class:`~beets.library.models.Item`
|
.. |Item| replace:: :class:`~beets.library.models.Item`
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ configuration files, respectively.
|
||||||
|
|
||||||
plugins/index
|
plugins/index
|
||||||
library
|
library
|
||||||
|
paths
|
||||||
importer
|
importer
|
||||||
cli
|
cli
|
||||||
../api/index
|
../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
|
Migration guidance
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
Older metadata plugins that extend :py:class:`beets.plugins.BeetsPlugin` should
|
Older metadata plugins that extend |BeetsPlugin| should be migrated to
|
||||||
be migrated to :py:class:`MetadataSourcePlugin`. Legacy support will be removed
|
:py:class:`MetadataSourcePlugin`. Legacy support will be removed in **beets
|
||||||
in **beets v3.0.0**.
|
v3.0.0**.
|
||||||
|
|
||||||
.. seealso::
|
.. seealso::
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,13 @@ registration process in this case:
|
||||||
:Parameters: ``info`` (|AlbumInfo|)
|
:Parameters: ``info`` (|AlbumInfo|)
|
||||||
:Description: Like ``trackinfo_received`` but for album-level metadata.
|
: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``
|
``before_choose_candidate``
|
||||||
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
|
:Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|)
|
||||||
:Description: Called before prompting the user during interactive import.
|
:Description: Called before prompting the user during interactive import.
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ or your plugin subpackage
|
||||||
anymore.
|
anymore.
|
||||||
|
|
||||||
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
|
The meat of your plugin goes in ``myawesomeplugin.py``. Every plugin has to
|
||||||
extend the :class:`beets.plugins.BeetsPlugin` abstract base class [2]_ . For
|
extend the |BeetsPlugin| abstract base class [2]_ . For instance, a minimal
|
||||||
instance, a minimal plugin without any functionality would look like this:
|
plugin without any functionality would look like this:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
|
@ -52,6 +52,12 @@ instance, a minimal plugin without any functionality would look like this:
|
||||||
class MyAwesomePlugin(BeetsPlugin):
|
class MyAwesomePlugin(BeetsPlugin):
|
||||||
pass
|
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
|
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
|
your ``beets`` (virtual) environment. To enable your plugin, add it it to the
|
||||||
beets configuration
|
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/
|
.. _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
|
When beets is in verbose mode, plugin messages are prefixed with the plugin name
|
||||||
to make them easier to see.
|
to make them easier to see.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ shall expose to the user:
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.ui.commands import PromptChoice
|
from beets.util import PromptChoice
|
||||||
|
|
||||||
|
|
||||||
class ExamplePlugin(BeetsPlugin):
|
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:
|
.. _bugs:
|
||||||
|
|
||||||
…report a bug in beets?
|
…report a bug in beets?
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~
|
-----------------------
|
||||||
|
|
||||||
We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please
|
We use the `issue tracker`_ on GitHub where you can `open a new ticket`_. Please
|
||||||
follow these guidelines when reporting an issue:
|
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
|
- Most importantly: if beets is crashing, please `include the traceback
|
||||||
<https://imgur.com/jacoj>`__. Tracebacks can be more readable if you put them
|
<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
|
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
|
- 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
|
import ...``) and include that with your bug report. Look through this verbose
|
||||||
output for any red flags that might point to the problem.
|
output for any red flags that might point to the problem.
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@ guide.
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
main
|
main
|
||||||
|
installation
|
||||||
tagger
|
tagger
|
||||||
advanced
|
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
|
Getting Started
|
||||||
===============
|
===============
|
||||||
|
|
||||||
Welcome to beets_! This guide will help you begin using it to make your music
|
Welcome to beets_! This guide will help get started with improving and
|
||||||
collection better.
|
organizing your music collection.
|
||||||
|
|
||||||
.. _beets: https://beets.io/
|
.. _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
|
.. include:: installation.rst
|
||||||
more recent Python installing it via Homebrew_ (``brew install python3``).
|
:start-after: <!-- start-quick-install -->
|
||||||
There's also a MacPorts_ port. Run ``port install beets`` or ``port install
|
:end-before: <!-- end-quick-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``.
|
|
||||||
|
|
||||||
.. _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
|
.. tab-set::
|
||||||
beets`` if you run into permissions problems).
|
|
||||||
|
|
||||||
To install without pip, download beets from `its PyPI page`_ and run ``python
|
.. tab-item:: Copy Files (Default)
|
||||||
setup.py install`` in the directory therein.
|
|
||||||
|
|
||||||
.. _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
|
import:
|
||||||
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
|
copy: yes # Copy files to new location
|
||||||
new versions.
|
|
||||||
|
|
||||||
.. _@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
|
Start with a new empty directory, but *move* new music in instead of copying it (saving disk space).
|
||||||
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_.)
|
|
||||||
|
|
||||||
If this happens, you can install beets for the current user only by typing ``pip
|
.. code-block:: yaml
|
||||||
install --user beets``. If you do that, you might want to add
|
|
||||||
``~/Library/Python/3.6/bin`` to your ``$PATH``.
|
|
||||||
|
|
||||||
.. _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
|
.. code-block:: yaml
|
||||||
get it right:
|
|
||||||
|
|
||||||
1. If you don't have it, `install Python`_ (you want at least Python 3.8). The
|
import:
|
||||||
installer should give you the option to "add Python to PATH." Check this box.
|
copy: no # Use files in place
|
||||||
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.
|
|
||||||
|
|
||||||
Windows users may also want to install a context menu item for importing files
|
.. tab-item:: Read-Only Mode
|
||||||
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".
|
|
||||||
|
|
||||||
Because I don't use Windows myself, I may have missed something. If you have
|
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.)
|
||||||
trouble or you have more detail to contribute here, please direct it to `the
|
|
||||||
mailing list`_.
|
|
||||||
|
|
||||||
.. _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
|
You can find a list of available plugins in the :doc:`plugins index
|
||||||
comfortable with light troubleshooting in tools like ``pip``, ``make``, and
|
</plugins/index>`.
|
||||||
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.
|
|
||||||
|
|
||||||
.. _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
|
Here's a sample configuration file that includes the settings mentioned above:
|
||||||
: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:
|
|
||||||
|
|
||||||
::
|
.. code-block:: yaml
|
||||||
|
|
||||||
directory: ~/music
|
directory: ~/music
|
||||||
library: ~/data/musiclibrary.db
|
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:
|
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
|
plugins:
|
||||||
files but instead just correct the tags on music. Put the line ``copy: no``
|
- musicbrainz # Example plugin for fetching metadata
|
||||||
under the ``import:`` heading in your config file to disable any copying or
|
# - ... other plugins you want ...
|
||||||
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:
|
|
||||||
|
|
||||||
::
|
You can copy and paste this into your config file and modify it as needed.
|
||||||
|
|
||||||
import:
|
.. admonition:: Ready for more?
|
||||||
copy: no
|
|
||||||
write: no
|
|
||||||
|
|
||||||
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
|
Importing Your Music
|
||||||
and file naming scheme. See :doc:`/reference/config` for a full reference.
|
--------------------
|
||||||
|
|
||||||
.. _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
|
.. important::
|
||||||
``beet version`` to see a list of enabled plugins or ``beet config`` to get a
|
|
||||||
complete listing of your current configuration.
|
|
||||||
|
|
||||||
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.
|
Choose Your Import Method
|
||||||
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.
|
|
||||||
|
|
||||||
There are two good ways to bring your existing library into beets. You can
|
There are two good ways to bring your *existing* library into beets database.
|
||||||
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
|
.. tab-set::
|
||||||
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
|
.. tab-item:: Autotag (Recommended)
|
||||||
about speed bears repeating: using the autotagger on a large library can take a
|
|
||||||
|
This method uses beets' autotagger to find canonical metadata for every album you import. It may take a while, especially for large libraries, and it's an interactive process. But it ensures all your songs' tags are exactly right from the get-go.
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
beet import /a/chunk/of/my/library
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
The point about speed bears repeating: using the autotagger on a large library can take a
|
||||||
very long time, and it's an interactive process. So set aside a good chunk of
|
very long time, and it's an interactive process. So set aside a good chunk of
|
||||||
time if you're going to go that route. For more on the interactive tagging
|
time if you're going to go that route.
|
||||||
|
|
||||||
|
We also recommend importing smaller batches of music at a time (e.g., a few albums) to make the process more manageable. For more on the interactive tagging
|
||||||
process, see :doc:`tagger`.
|
process, see :doc:`tagger`.
|
||||||
|
|
||||||
If you've got time and want to tag all your music right once and for all, do
|
|
||||||
this:
|
|
||||||
|
|
||||||
::
|
.. tab-item:: Quick Import
|
||||||
|
|
||||||
$ beet import /path/to/my/music
|
This method quickly brings all your files with all their current metadata into beets' database without any changes. It's really fast, but it doesn't clean up or correct any tags.
|
||||||
|
|
||||||
(Note that by default, this command will *copy music into the directory you
|
To use this method, run:
|
||||||
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 /my/huge/mp3/library
|
beet import --noautotag /my/huge/mp3/library
|
||||||
|
|
||||||
Note that you just need to add ``-A`` for "don't autotag".
|
The ``--noautotag`` / ``-A`` flag skips autotagging and uses your files' current metadata.
|
||||||
|
|
||||||
Adding More Music
|
.. admonition:: More Import Options
|
||||||
-----------------
|
|
||||||
|
|
||||||
If you've ripped or... otherwise obtained some new music, you can add it with
|
The ``beet import`` command has many options to customize its behavior. For
|
||||||
the ``beet import`` command, the same way you imported your library. Like so:
|
a full list, type ``beet help import`` or see the :ref:`import command
|
||||||
|
reference <import-cmd>`.
|
||||||
|
|
||||||
::
|
Adding More Music Later
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
$ beet import ~/some_great_album
|
When you acquire new music, use the same ``beet import`` command to add it to
|
||||||
|
your library:
|
||||||
|
|
||||||
This will attempt to autotag the new album (interactively) and add it to your
|
.. code-block:: console
|
||||||
library. There are, of course, more options for this command---just type ``beet
|
|
||||||
help import`` to see what's available.
|
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
|
Seeing Your Music
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
If you want to query your music library, the ``beet list`` (shortened to ``beet
|
Once you've imported music into beets, you'll want to explore and query your
|
||||||
ls``) command is for you. You give it a :doc:`query string </reference/query>`,
|
library. Beets provides several commands for searching, browsing, and getting
|
||||||
which is formatted something like a Google search, and it gives you a list of
|
statistics about your collection.
|
||||||
songs. Thus:
|
|
||||||
|
|
||||||
::
|
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
|
$ beet ls the magnetic fields
|
||||||
The Magnetic Fields - Distortion - Three-Way
|
The Magnetic Fields - Distortion - Three-Way
|
||||||
The Magnetic Fields - Distortion - California Girls
|
The Magnetic Fields - Dist
|
||||||
The Magnetic Fields - Distortion - Old Fools
|
The Magnetic Fields - Distortion - Old Fools
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
$ beet ls hissing gronlandic
|
$ beet ls hissing gronlandic
|
||||||
of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit
|
of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
$ beet ls bird
|
$ beet ls bird
|
||||||
The Knife - The Knife - Bird
|
The Knife - The Knife - Bird
|
||||||
The Mae Shi - Terrorbird - Revelation Six
|
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
|
$ beet ls album:bird
|
||||||
The Mae Shi - Terrorbird - Revelation Six
|
The Mae Shi - Terrorbird - Revelation Six
|
||||||
|
|
||||||
By default, a search term will match any of a handful of :ref:`common attributes
|
This searches only the ``album`` field for the term ``bird``.
|
||||||
<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
|
Searching for Albums
|
||||||
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.)
|
|
||||||
|
|
||||||
The ``beet list`` command also has an ``-a`` option, which searches for albums
|
The ``beet list`` command also has an ``-a`` option, which searches for albums
|
||||||
instead of songs:
|
instead of songs:
|
||||||
|
|
||||||
::
|
.. code-block:: console
|
||||||
|
|
||||||
$ beet ls -a forever
|
$ beet ls -a forever
|
||||||
Bon Iver - For Emma, Forever Ago
|
Bon Iver - For Emma, Forever Ago
|
||||||
Freezepop - Freezepop Forever
|
Freezepop - Freezepop Forever
|
||||||
|
|
||||||
|
Custom Output Formatting
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
There's also an ``-f`` option (for *format*) that lets you specify what gets
|
There's also an ``-f`` option (for *format*) that lets you specify what gets
|
||||||
displayed in the results of a search:
|
displayed in the results of a search:
|
||||||
|
|
||||||
::
|
.. code-block:: console
|
||||||
|
|
||||||
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
|
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
|
||||||
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
|
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
|
||||||
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
|
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
|
||||||
|
|
||||||
In the format option, field references like ``$format`` and ``$year`` are filled
|
In the format string, field references like ``$format``, ``$year``, ``$album``,
|
||||||
in with data from each result. You can see a full list of available fields by
|
etc., are replaced with data from each result.
|
||||||
running ``beet fields``.
|
|
||||||
|
|
||||||
Beets also has a ``stats`` command, just in case you want to see how much music
|
.. dropdown:: Available fields for formatting
|
||||||
you have:
|
|
||||||
|
|
||||||
::
|
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
|
$ beet stats
|
||||||
Tracks: 13019
|
Tracks: 13019
|
||||||
|
|
@ -325,31 +313,107 @@ you have:
|
||||||
Artists: 548
|
Artists: 548
|
||||||
Albums: 1094
|
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
|
Keep Playing
|
||||||
------------
|
------------
|
||||||
|
|
||||||
This is only the beginning of your long and prosperous journey with beets. To
|
Congratulations! You've now mastered the basics of beets. But this is only the
|
||||||
keep learning, take a look at :doc:`advanced` for a sampling of what else is
|
beginning, beets has many more powerful features to explore.
|
||||||
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.)
|
|
||||||
|
|
||||||
Also, check out :doc:`beets' plugins </plugins/index>`. The real power of beets
|
Continue Your Learning Journey
|
||||||
is in its extensibility---with plugins, beets can do almost anything for your
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
music collection.
|
|
||||||
|
|
||||||
You can always get help using the ``beet help`` command. The plain ``beet help``
|
*I was there to push people beyond what's expected of them.*
|
||||||
command lists all the available commands; then, for example, ``beet help
|
|
||||||
import`` gives more specific help about the ``import`` command.
|
|
||||||
|
|
||||||
If you need more of a walkthrough, you can read an illustrated one `on the beets
|
.. grid:: 2
|
||||||
blog <https://beets.io/blog/walkthrough.html>`_.
|
:gutter: 3
|
||||||
|
|
||||||
Please let us know what you think of beets via `the discussion board`_ or
|
.. grid-item-card:: :octicon:`zap` Advanced Techniques
|
||||||
Mastodon_.
|
: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.
|
we'll try to improve this guide.
|
||||||
|
|
||||||
.. _the discussion board: https://github.com/beetbox/beets/discussions/
|
.. _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>`.
|
be interested in exploring the :doc:`plugins </plugins/index>`.
|
||||||
|
|
||||||
If you still need help, you can drop by the ``#beets`` IRC channel on
|
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`_,
|
Libera.Chat, drop by `the discussion board`_ or `file a bug`_ in the issue
|
||||||
or `file a bug`_ in the issue tracker. Please let us know where you think this
|
tracker. Please let us know where you think this documentation can be improved.
|
||||||
documentation can be improved.
|
|
||||||
|
|
||||||
.. _beets: https://beets.io/
|
.. _beets: https://beets.io/
|
||||||
|
|
||||||
|
|
@ -23,8 +22,6 @@ documentation can be improved.
|
||||||
|
|
||||||
.. _the discussion board: https://github.com/beetbox/beets/discussions/
|
.. _the discussion board: https://github.com/beetbox/beets/discussions/
|
||||||
|
|
||||||
.. _the mailing list: https://groups.google.com/group/beets-users
|
|
||||||
|
|
||||||
Contents
|
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
|
embedding is disabled for files that are linked. Refer to the ``link`` and
|
||||||
``hardlink`` options below.
|
``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
|
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
|
||||||
playlist file in the destination folder given by the ``-d`` (``--dest``) option
|
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
|
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.
|
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
|
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.
|
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
|
- **no_convert**: Does not transcode items matching the query string provided
|
||||||
(see :doc:`/reference/query`). For example, to not convert AAC or WMA formats,
|
(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
|
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.,
|
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
|
- **never_convert_lossy_files**: Cross-conversions between lossy codecs---such
|
||||||
as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality
|
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:
|
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.
|
- **paths**: The directory structure and naming scheme for the converted files.
|
||||||
Uses the same format as the top-level ``paths`` section (see
|
Uses the same format as the top-level ``paths`` section (see
|
||||||
:ref:`path-format-config`). Default: Reuse your top-level path format
|
:ref:`path-format-config`). Default: Reuse your top-level path format
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,23 @@ Default
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
deezer:
|
deezer:
|
||||||
|
search_query_ascii: no
|
||||||
data_source_mismatch_penalty: 0.5
|
data_source_mismatch_penalty: 0.5
|
||||||
search_limit: 5
|
search_limit: 5
|
||||||
search_query_ascii: no
|
|
||||||
|
|
||||||
- **search_query_ascii**: If set to ``yes``, the search query will be converted
|
.. conf:: search_query_ascii
|
||||||
to ASCII before being sent to Deezer. Converting searches to ASCII can enhance
|
:default: no
|
||||||
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
|
If enabled, the search query will be converted to ASCII before being sent to
|
||||||
album:4x4`` (notice ``×!=x``). Default: ``no``.
|
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 ``deezer`` plugin provides an additional command ``deezerupdate`` to update
|
||||||
the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a
|
the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a
|
||||||
|
|
|
||||||
|
|
@ -71,21 +71,29 @@ Default
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
discogs:
|
discogs:
|
||||||
data_source_mismatch_penalty: 0.5
|
|
||||||
search_limit: 5
|
|
||||||
apikey: REDACTED
|
apikey: REDACTED
|
||||||
apisecret: REDACTED
|
apisecret: REDACTED
|
||||||
tokenfile: discogs_token.json
|
tokenfile: discogs_token.json
|
||||||
user_token: REDACTED
|
user_token:
|
||||||
index_tracks: no
|
index_tracks: no
|
||||||
append_style_genre: no
|
append_style_genre: no
|
||||||
separator: ', '
|
separator: ', '
|
||||||
strip_disambiguation: yes
|
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
|
.. conf:: index_tracks
|
||||||
headers, mark divisions between distinct works on the same release or within
|
:default: no
|
||||||
works. When enabled, beets will incorporate the names of the divisions
|
|
||||||
containing each track into the imported track's title. 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:
|
For example, importing `divisions album`_ would result in track names like:
|
||||||
|
|
||||||
|
|
@ -105,20 +113,36 @@ Default
|
||||||
|
|
||||||
This option is useful when importing classical music.
|
This option is useful when importing classical music.
|
||||||
|
|
||||||
- **append_style_genre**: Appends the Discogs style (if found) to the genre tag.
|
.. conf:: append_style_genre
|
||||||
This can be useful if you want more granular genres to categorize your music.
|
:default: no
|
||||||
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
|
Appends the Discogs style (if found) to the genre tag. This can be useful if
|
||||||
"Electronic, Techno" (assuming default separator of ``", "``) instead of just
|
you want more granular genres to categorize your music. For example,
|
||||||
"Electronic". Default: ``False``
|
a release in Discogs might have a genre of "Electronic" and a style of
|
||||||
- **separator**: How to join multiple genre and style values from Discogs into a
|
"Techno": enabling this setting would set the genre to be "Electronic,
|
||||||
string. Default: ``", "``
|
Techno" (assuming default separator of ``", "``) instead of just
|
||||||
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
|
"Electronic".
|
||||||
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``
|
.. conf:: separator
|
||||||
- **featured_string**: Configure the string used for noting featured artists.
|
:default: ", "
|
||||||
Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.``
|
|
||||||
- **anv**: These configuration option are dedicated to handling Artist Name
|
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
|
Variations (ANVs). Sometimes a release credits artists differently compared to
|
||||||
the majority of their work. For example, "Basement Jaxx" may be credited as
|
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
|
||||||
|
|
@ -129,9 +153,11 @@ Default
|
||||||
|
|
||||||
discogs:
|
discogs:
|
||||||
anv:
|
anv:
|
||||||
artist_credit: True
|
artist_credit: yes
|
||||||
artist: False
|
artist: no
|
||||||
album_artist: False
|
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
|
.. _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 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:
|
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
|
- **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
|
useful if you still want to be able to search for features in the artist
|
||||||
field. Default: ``no``.
|
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
|
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