mirror of
https://github.com/beetbox/beets.git
synced 2026-03-18 19:33:01 +01:00
Merge branch 'master' into usertag
This commit is contained in:
commit
0efce4a86b
388 changed files with 36864 additions and 26891 deletions
|
|
@ -43,3 +43,47 @@ a6e5201ff3fad4c69bf24d17bace2ef744b9f51b
|
|||
f36bc497c8c8f89004f3f6879908d3f0b25123e1
|
||||
# Remove some lint exclusions and fix the issues
|
||||
5f78d1b82b2292d5ce0c99623ba0ec444b80d24c
|
||||
|
||||
# 2025
|
||||
# Fix formatting
|
||||
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
|
||||
# Importer restructure
|
||||
9147577b2b19f43ca827e9650261a86fb0450cef
|
||||
# Move functionality under MusicBrainz plugin
|
||||
529aaac7dced71266c6d69866748a7d044ec20ff
|
||||
# musicbrainz: reorder methods
|
||||
5dc6f45110b99f0cc8dbb94251f9b1f6d69583fa
|
||||
# Copy paste query, types from library to dbcore
|
||||
1a045c91668c771686f4c871c84f1680af2e944b
|
||||
# Library restructure (split library.py into multiple modules)
|
||||
0ad4e19d4f870db757373f44d12ff3be2441363a
|
||||
# Docs: fix linting issues
|
||||
769dcdc88a1263638ae25944ba6b2be3e8933666
|
||||
# Reformat all docs using docstrfmt
|
||||
ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d
|
||||
# Replace format calls with f-strings
|
||||
4a361bd501e85de12c91c2474c423559ca672852
|
||||
# Replace percent formatting
|
||||
9352a79e4108bd67f7e40b1e944c01e0a7353272
|
||||
# Replace string concatenation (' + ')
|
||||
1c16b2b3087e9c3635d68d41c9541c4319d0bdbe
|
||||
# Do not use backslashes to deal with long strings
|
||||
2fccf64efe82851861e195b521b14680b480a42a
|
||||
# Do not use explicit indices for logging args when not needed
|
||||
d93ddf8dd43e4f9ed072a03829e287c78d2570a2
|
||||
# Moved dev docs
|
||||
07549ed896d9649562d40b75cd30702e6fa6e975
|
||||
# Moved plugin docs Further Reading chapter
|
||||
33f1a5d0bef8ca08be79ee7a0d02a018d502680d
|
||||
# Moved art.py utility module from beets into beetsplug
|
||||
28aee0fde463f1e18dfdba1994e2bdb80833722f
|
||||
# Refactor `ui/commands.py` into multiple modules
|
||||
59c93e70139f70e9fd1c6f3c1bceb005945bec33
|
||||
# Moved ui.commands._utils into ui.commands.utils
|
||||
25ae330044abf04045e3f378f72bbaed739fb30d
|
||||
# Refactor test_ui_command.py into multiple modules
|
||||
a59e41a88365e414db3282658d2aa456e0b3468a
|
||||
# pyupgrade Python 3.10
|
||||
301637a1609831947cb5dd90270ed46c24b1ab1b
|
||||
# Fix changelog formatting
|
||||
658b184c59388635787b447983ecd3a575f4fe56
|
||||
|
|
|
|||
7
.github/CODEOWNERS
vendored
Normal file
7
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# assign the entire repo to the maintainers team
|
||||
* @beetbox/maintainers
|
||||
|
||||
# Specific ownerships:
|
||||
/beets/metadata_plugins.py @semohr
|
||||
/beetsplug/titlecase.py @henry-oberholtzer
|
||||
/beetsplug/mbpseudo.py @asardaes
|
||||
16
.github/problem-matchers/sphinx-build.json
vendored
Normal file
16
.github/problem-matchers/sphinx-build.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "sphinx-build",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(/[^:]+):((\\d+):)?(\\sWARNING:)?\\s*(.+)$",
|
||||
"file": 1,
|
||||
"line": 3,
|
||||
"message": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
.github/problem-matchers/sphinx-lint.json
vendored
Normal file
17
.github/problem-matchers/sphinx-lint.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "sphinx-lint",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^([^:]+):(\\d+):\\s+(.*)\\s\\(([a-z-]+)\\)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 3,
|
||||
"code": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
15
.github/sphinx-problem-matcher.json
vendored
15
.github/sphinx-problem-matcher.json
vendored
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "sphinx",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^([^:]+):(\\d+): (WARNING: )?(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.github/workflows/changelog_reminder.yaml
vendored
14
.github/workflows/changelog_reminder.yaml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Verify changelog updated
|
||||
|
||||
on:
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
|
@ -10,24 +10,24 @@ jobs:
|
|||
check_changes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get all updated Python files
|
||||
id: changed-python-files
|
||||
uses: tj-actions/changed-files@v44
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
**.py
|
||||
|
||||
- name: Check for the changelog update
|
||||
id: changelog-update
|
||||
uses: tj-actions/changed-files@v44
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: docs/changelog.rst
|
||||
|
||||
|
||||
- name: Comment under the PR with a reminder
|
||||
if: steps.changed-python-files.outputs.any_changed == 'true' && steps.changelog-update.outputs.any_changed == 'false'
|
||||
uses: thollander/actions-comment-pull-request@v2
|
||||
with:
|
||||
message: 'Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
message: 'Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
|
|
|||
44
.github/workflows/ci.yaml
vendored
44
.github/workflows/ci.yaml
vendored
|
|
@ -4,6 +4,12 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
# Cancel previous workflow run when a new commit is pushed to a feature branch
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
PY_COLORS: 1
|
||||
|
||||
|
|
@ -14,17 +20,17 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.9' && matrix.platform == 'ubuntu-latest' }}
|
||||
IS_MAIN_PYTHON: ${{ matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- name: Setup Python with poetry caching
|
||||
# poetry cache requires poetry to already be installed, weirdly
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: poetry
|
||||
|
|
@ -33,11 +39,19 @@ jobs:
|
|||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg gobject-introspection libcairo2-dev libgirepository1.0-dev pandoc
|
||||
sudo apt install --yes --no-install-recommends \
|
||||
ffmpeg \
|
||||
gobject-introspection \
|
||||
gstreamer1.0-plugins-base \
|
||||
python3-gst-1.0 \
|
||||
libcairo2-dev \
|
||||
libgirepository-2.0-dev \
|
||||
pandoc \
|
||||
imagemagick
|
||||
|
||||
- name: Get changed lyrics files
|
||||
id: lyrics-update
|
||||
uses: tj-actions/changed-files@v45
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
beetsplug/lyrics.py
|
||||
|
|
@ -52,7 +66,7 @@ jobs:
|
|||
- if: ${{ env.IS_MAIN_PYTHON != 'true' }}
|
||||
name: Test without coverage
|
||||
run: |
|
||||
poetry install --extras=autobpm --extras=lyrics
|
||||
poetry install --without=lint --extras=autobpm --extras=lyrics --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate
|
||||
poe test
|
||||
|
||||
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
|
||||
|
|
@ -60,10 +74,16 @@ jobs:
|
|||
env:
|
||||
LYRICS_UPDATED: ${{ steps.lyrics-update.outputs.any_changed }}
|
||||
run: |
|
||||
poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink
|
||||
poetry install --extras=autobpm --extras=lyrics --extras=docs --extras=replaygain --extras=reflink --extras=fetchart --extras=chroma --extras=sonosupdate
|
||||
poe docs
|
||||
poe test-with-coverage
|
||||
|
||||
- if: ${{ !cancelled() }}
|
||||
name: Upload test results to Codecov
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
|
||||
name: Store the coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
@ -78,15 +98,15 @@ jobs:
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get the coverage report
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: coverage-report
|
||||
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
use_oidc: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) }}
|
||||
|
|
|
|||
12
.github/workflows/integration_test.yaml
vendored
12
.github/workflows/integration_test.yaml
vendored
|
|
@ -3,16 +3,20 @@ on:
|
|||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * SUN" # run every Sunday at midnight
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.10"
|
||||
|
||||
jobs:
|
||||
test_integration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
|
|
@ -6,8 +6,13 @@ on:
|
|||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
# Cancel previous workflow run when a new commit is pushed to a feature branch
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.9
|
||||
PYTHON_VERSION: "3.10"
|
||||
|
||||
jobs:
|
||||
changed-files:
|
||||
|
|
@ -19,16 +24,16 @@ jobs:
|
|||
changed_doc_files: ${{ steps.changed-doc-files.outputs.all_changed_files }}
|
||||
changed_python_files: ${{ steps.changed-python-files.outputs.all_changed_files }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Get changed docs files
|
||||
id: changed-doc-files
|
||||
uses: tj-actions/changed-files@v44
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
docs/**
|
||||
- name: Get changed python files
|
||||
id: raw-changed-python-files
|
||||
uses: tj-actions/changed-files@v44
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
**.py
|
||||
|
|
@ -51,10 +56,10 @@ jobs:
|
|||
name: Check formatting
|
||||
needs: changed-files
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
|
@ -72,10 +77,10 @@ jobs:
|
|||
name: Check linting
|
||||
needs: changed-files
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
|
@ -92,10 +97,10 @@ jobs:
|
|||
name: Check types with mypy
|
||||
needs: changed-files
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
|
@ -105,10 +110,9 @@ jobs:
|
|||
|
||||
- name: Type check code
|
||||
uses: liskin/gh-problem-matcher-wrap@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
linters: mypy
|
||||
run: poe check-types --show-column-numbers --no-error-summary ${{ needs.changed-files.outputs.changed_python_files }}
|
||||
run: poe check-types --show-column-numbers --no-error-summary .
|
||||
|
||||
docs:
|
||||
if: needs.changed-files.outputs.any_docs_changed == 'true'
|
||||
|
|
@ -116,10 +120,10 @@ jobs:
|
|||
name: Check docs
|
||||
needs: changed-files
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
|
@ -127,11 +131,16 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: poetry install --extras=docs
|
||||
|
||||
- name: Add Sphinx problem matcher
|
||||
run: echo "::add-matcher::.github/sphinx-problem-matcher.json"
|
||||
- name: Add Sphinx problem matchers
|
||||
run: |
|
||||
echo "::add-matcher::.github/problem-matchers/sphinx-build.json"
|
||||
echo "::add-matcher::.github/problem-matchers/sphinx-lint.json"
|
||||
|
||||
- name: Check docs formatting
|
||||
run: poe format-docs --check
|
||||
|
||||
- name: Lint docs
|
||||
run: poe lint-docs
|
||||
|
||||
- name: Build docs
|
||||
run: |-
|
||||
poe docs |& tee /tmp/output
|
||||
# fail the job if there are issues
|
||||
grep -q " WARNING:" /tmp/output && exit 1 || exit 0
|
||||
run: poe docs -- -e 'SPHINXOPTS=--fail-on-warning --keep-going'
|
||||
20
.github/workflows/make_release.yaml
vendored
20
.github/workflows/make_release.yaml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
required: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.8
|
||||
PYTHON_VERSION: "3.10"
|
||||
NEW_VERSION: ${{ inputs.version }}
|
||||
NEW_TAG: v${{ inputs.version }}
|
||||
|
||||
|
|
@ -17,16 +17,16 @@ jobs:
|
|||
name: Bump version, commit and create tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
||||
- name: Install dependencies
|
||||
run: poetry install --only=release
|
||||
run: poetry install --with=release --extras=docs
|
||||
|
||||
- name: Bump project version
|
||||
run: poe bump "${{ env.NEW_VERSION }}"
|
||||
|
|
@ -45,13 +45,13 @@ jobs:
|
|||
outputs:
|
||||
changelog: ${{ steps.generate_changelog.outputs.changelog }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.NEW_TAG }}
|
||||
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v0.1.1
|
||||
- uses: actions/setup-python@v5
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: poetry
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
id-token: write
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
|
@ -107,7 +107,7 @@ jobs:
|
|||
CHANGELOG: ${{ needs.build.outputs.changelog }}
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -94,3 +94,6 @@ ENV/
|
|||
|
||||
# pyright
|
||||
pyrightconfig.json
|
||||
|
||||
# Pyrefly
|
||||
pyrefly.toml
|
||||
|
|
|
|||
|
|
@ -2,7 +2,17 @@
|
|||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.1
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
- id: format
|
||||
name: Format Python files
|
||||
entry: poe format
|
||||
language: system
|
||||
files: '.*.py'
|
||||
pass_filenames: true
|
||||
- id: format-docs
|
||||
name: Format docs
|
||||
entry: poe format-docs
|
||||
language: system
|
||||
files: '.*.rst'
|
||||
pass_filenames: true
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
####################################
|
||||
Contributor Covenant Code of Conduct
|
||||
####################################
|
||||
====================================
|
||||
|
||||
Our Pledge
|
||||
==========
|
||||
----------
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
|
|
@ -16,7 +15,7 @@ We pledge to act and interact in ways that contribute to an open, welcoming,
|
|||
diverse, inclusive, and healthy community.
|
||||
|
||||
Our Standards
|
||||
=============
|
||||
-------------
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
|
@ -41,7 +40,7 @@ Examples of unacceptable behavior include:
|
|||
professional setting
|
||||
|
||||
Enforcement Responsibilities
|
||||
============================
|
||||
----------------------------
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
|
|
@ -54,7 +53,7 @@ not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|||
decisions when appropriate.
|
||||
|
||||
Scope
|
||||
=====
|
||||
-----
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
|
@ -63,7 +62,7 @@ posting via an official social media account, or acting as an appointed
|
|||
representative at an online or offline event.
|
||||
|
||||
Enforcement
|
||||
===========
|
||||
-----------
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at here on Github.
|
||||
|
|
@ -73,13 +72,13 @@ All community leaders are obligated to respect the privacy and security of the
|
|||
reporter of any incident.
|
||||
|
||||
Enforcement Guidelines
|
||||
======================
|
||||
----------------------
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
1. Correction
|
||||
-------------
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
|
@ -89,7 +88,7 @@ clarity around the nature of the violation and an explanation of why the
|
|||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
2. Warning
|
||||
----------
|
||||
~~~~~~~~~~
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
|
@ -102,7 +101,7 @@ like social media. Violating these terms may lead to a temporary or permanent
|
|||
ban.
|
||||
|
||||
3. Temporary Ban
|
||||
----------------
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
|
@ -114,7 +113,7 @@ with those enforcing the Code of Conduct, is allowed during this period.
|
|||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
4. Permanent Ban
|
||||
----------------
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
|
|
@ -124,7 +123,7 @@ individual, or aggression toward or disparagement of classes of individuals.
|
|||
community.
|
||||
|
||||
Attribution
|
||||
===========
|
||||
-----------
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available `here
|
||||
|
|
@ -136,4 +135,3 @@ enforcement ladder.
|
|||
For answers to common questions about this code of conduct, see the `FAQ
|
||||
<https://www.contributor-covenant.org/faq>`_. Translations are available at
|
||||
`translations <https://www.contributor-covenant.org/translations>`_.
|
||||
|
||||
|
|
|
|||
455
CONTRIBUTING.rst
455
CONTRIBUTING.rst
|
|
@ -1,102 +1,106 @@
|
|||
############
|
||||
Contributing
|
||||
############
|
||||
============
|
||||
|
||||
.. contents::
|
||||
:depth: 3
|
||||
|
||||
Thank you!
|
||||
==========
|
||||
----------
|
||||
|
||||
First off, thank you for considering contributing to beets! It’s people
|
||||
like you that make beets continue to succeed.
|
||||
First off, thank you for considering contributing to beets! It’s people like you
|
||||
that make beets continue to succeed.
|
||||
|
||||
These guidelines describe how you can help most effectively. By
|
||||
following these guidelines, you can make life easier for the development
|
||||
team as it indicates you respect the maintainers’ time; in return, the
|
||||
maintainers will reciprocate by helping to address your issue, review
|
||||
changes, and finalize pull requests.
|
||||
These guidelines describe how you can help most effectively. By following these
|
||||
guidelines, you can make life easier for the development team as it indicates
|
||||
you respect the maintainers’ time; in return, the maintainers will reciprocate
|
||||
by helping to address your issue, review changes, and finalize pull requests.
|
||||
|
||||
Types of Contributions
|
||||
======================
|
||||
----------------------
|
||||
|
||||
We love to get contributions from our community—you! There are many ways
|
||||
to contribute, whether you’re a programmer or not.
|
||||
We love to get contributions from our community—you! There are many ways to
|
||||
contribute, whether you’re a programmer or not.
|
||||
|
||||
The first thing to do, regardless of how you'd like to contribute to the
|
||||
project, is to check out our :doc:`Code of Conduct <code_of_conduct>` and to
|
||||
keep that in mind while interacting with other contributors and users.
|
||||
|
||||
Non-Programming
|
||||
---------------
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
- Promote beets! Help get the word out by telling your friends, writing
|
||||
a blog post, or discussing it on a forum you frequent.
|
||||
- Improve the `documentation`_. It’s
|
||||
incredibly easy to contribute here: just find a page you want to
|
||||
modify and hit the “Edit on GitHub” button in the upper-right. You
|
||||
can automatically send us a pull request for your changes.
|
||||
- GUI design. For the time being, beets is a command-line-only affair.
|
||||
But that’s mostly because we don’t have any great ideas for what a
|
||||
good GUI should look like. If you have those great ideas, please get
|
||||
in touch.
|
||||
- Benchmarks. We’d like to have a consistent way of measuring speed
|
||||
improvements in beets’ tagger and other functionality as well as a
|
||||
way of comparing beets’ performance to other tools. You can help by
|
||||
compiling a library of freely-licensed music files (preferably with
|
||||
incorrect metadata) for testing and measurement.
|
||||
- Think you have a nice config or cool use-case for beets? We’d love to
|
||||
hear about it! Submit a post to our `discussion board
|
||||
<https://github.com/beetbox/beets/discussions/categories/show-and-tell>`__
|
||||
under the “Show and Tell” category for a chance to get featured in `the
|
||||
docs <https://beets.readthedocs.io/en/stable/guides/advanced.html>`__.
|
||||
- Consider helping out fellow users by by `responding to support requests
|
||||
<https://github.com/beetbox/beets/discussions/categories/q-a>`__ .
|
||||
- Promote beets! Help get the word out by telling your friends, writing a blog
|
||||
post, or discussing it on a forum you frequent.
|
||||
- Improve the documentation_. It’s incredibly easy to contribute here: just find
|
||||
a page you want to modify and hit the “Edit on GitHub” button in the
|
||||
upper-right. You can automatically send us a pull request for your changes.
|
||||
- GUI design. For the time being, beets is a command-line-only affair. But
|
||||
that’s mostly because we don’t have any great ideas for what a good GUI should
|
||||
look like. If you have those great ideas, please get in touch.
|
||||
- Benchmarks. We’d like to have a consistent way of measuring speed improvements
|
||||
in beets’ tagger and other functionality as well as a way of comparing beets’
|
||||
performance to other tools. You can help by compiling a library of
|
||||
freely-licensed music files (preferably with incorrect metadata) for testing
|
||||
and measurement.
|
||||
- Think you have a nice config or cool use-case for beets? We’d love to hear
|
||||
about it! Submit a post to our `discussion board
|
||||
<https://github.com/beetbox/beets/discussions/categories/show-and-tell>`__
|
||||
under the “Show and Tell” category for a chance to get featured in `the docs
|
||||
<https://beets.readthedocs.io/en/stable/guides/advanced.html>`__.
|
||||
- Consider helping out fellow users by by `responding to support requests
|
||||
<https://github.com/beetbox/beets/discussions/categories/q-a>`__ .
|
||||
|
||||
Programming
|
||||
-----------
|
||||
~~~~~~~~~~~
|
||||
|
||||
- As a programmer (even if you’re just a beginner!), you have a ton of
|
||||
opportunities to get your feet wet with beets.
|
||||
- For developing plugins, or hacking away at beets, there’s some good
|
||||
information in the `“For Developers” section of the
|
||||
docs <https://beets.readthedocs.io/en/stable/dev/>`__.
|
||||
- As a programmer (even if you’re just a beginner!), you have a ton of
|
||||
opportunities to get your feet wet with beets.
|
||||
- For developing plugins, or hacking away at beets, there’s some good
|
||||
information in the `“For Developers” section of the docs
|
||||
<https://beets.readthedocs.io/en/stable/dev/>`__.
|
||||
|
||||
.. _development-tools:
|
||||
|
||||
Development Tools
|
||||
^^^^^^^^^^^^^^^^^
|
||||
+++++++++++++++++
|
||||
|
||||
In order to develop beets, you will need a few tools installed:
|
||||
|
||||
- `poetry`_ for packaging, virtual environment and dependency management
|
||||
- `poethepoet`_ to run tasks, such as linting, formatting, testing
|
||||
- poetry_ for packaging, virtual environment and dependency management
|
||||
- poethepoet_ to run tasks, such as linting, formatting, testing
|
||||
|
||||
Python community recommends using `pipx`_ to install stand-alone command-line
|
||||
applications such as above. `pipx`_ installs each application in an isolated
|
||||
Python community recommends using pipx_ to install stand-alone command-line
|
||||
applications such as above. pipx_ installs each application in an isolated
|
||||
virtual environment, where its dependencies will not interfere with your system
|
||||
and other CLI tools.
|
||||
|
||||
If you do not have `pipx`_ installed in your system, follow `pipx-installation-instructions`_ or
|
||||
If you do not have pipx_ installed in your system, follow `pipx installation
|
||||
instructions <https://pipx.pypa.io/stable/installation/>`__ or
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
$ python3 -m pip install --user pipx
|
||||
|
||||
Install `poetry`_ and `poethepoet`_ using `pipx`_::
|
||||
Install poetry_ and poethepoet_ using pipx_:
|
||||
|
||||
::
|
||||
|
||||
$ pipx install poetry poethepoet
|
||||
|
||||
.. _pipx: https://pipx.pypa.io/stable
|
||||
.. _pipx-installation-instructions: https://pipx.pypa.io/stable/installation/
|
||||
.. admonition:: Check ``tool.pipx-install`` section in ``pyproject.toml`` to see supported versions
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
[tool.pipx-install]
|
||||
poethepoet = ">=0.26"
|
||||
poetry = "<2"
|
||||
|
||||
.. _getting-the-source:
|
||||
|
||||
Getting the Source
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
++++++++++++++++++
|
||||
|
||||
The easiest way to get started with the latest beets source is to clone the
|
||||
repository and install ``beets`` in a local virtual environment using `poetry`_.
|
||||
repository and install ``beets`` in a local virtual environment using poetry_.
|
||||
This can be done with:
|
||||
|
||||
.. code-block:: bash
|
||||
|
|
@ -106,26 +110,32 @@ This can be done with:
|
|||
$ poetry install
|
||||
|
||||
This will install ``beets`` and all development dependencies into its own
|
||||
virtual environment in your ``$POETRY_CACHE_DIR``. See ``poetry install
|
||||
--help`` for installation options, including installing ``extra`` dependencies
|
||||
for plugins.
|
||||
virtual environment in your ``$POETRY_CACHE_DIR``. See ``poetry install --help``
|
||||
for installation options, including installing ``extra`` dependencies for
|
||||
plugins.
|
||||
|
||||
In order to run something within this virtual environment, start the command
|
||||
with ``poetry run`` to them, for example ``poetry run pytest``.
|
||||
|
||||
On the other hand, it may get tedious to type ``poetry run`` before every
|
||||
command. Instead, you can activate the virtual environment in your shell with::
|
||||
command. Instead, you can activate the virtual environment in your shell with:
|
||||
|
||||
::
|
||||
|
||||
$ poetry shell
|
||||
|
||||
You should see ``(beets-py3.9)`` prefix in your shell prompt. Now you can run
|
||||
commands directly, for example::
|
||||
You should see ``(beets-py3.10)`` prefix in your shell prompt. Now you can run
|
||||
commands directly, for example:
|
||||
|
||||
$ (beets-py3.9) pytest
|
||||
::
|
||||
|
||||
Additionally, `poethepoet`_ task runner assists us with the most common
|
||||
$ (beets-py3.10) pytest
|
||||
|
||||
Additionally, poethepoet_ task runner assists us with the most common
|
||||
operations. Formatting, linting, testing are defined as ``poe`` tasks in
|
||||
`pyproject.toml`_. Run::
|
||||
pyproject.toml_. Run:
|
||||
|
||||
::
|
||||
|
||||
$ poe
|
||||
|
||||
|
|
@ -140,204 +150,180 @@ to see all available tasks. They can be used like this, for example
|
|||
$ poe test --lf # re-run failing tests (note the additional pytest option)
|
||||
$ poe check-types --pretty # check types with an extra option for mypy
|
||||
|
||||
|
||||
Code Contribution Ideas
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
+++++++++++++++++++++++
|
||||
|
||||
- We maintain a set of `issues marked as
|
||||
“bite-sized” <https://github.com/beetbox/beets/labels/bitesize>`__.
|
||||
These are issues that would serve as a good introduction to the
|
||||
codebase. Claim one and start exploring!
|
||||
- Like testing? Our `test
|
||||
coverage <https://codecov.io/github/beetbox/beets>`__ is somewhat
|
||||
low. You can help out by finding low-coverage modules or checking out
|
||||
other `testing-related
|
||||
issues <https://github.com/beetbox/beets/labels/testing>`__.
|
||||
- There are several ways to improve the tests in general (see :ref:`testing` and some
|
||||
places to think about performance optimization (see
|
||||
`Optimization <https://github.com/beetbox/beets/wiki/Optimization>`__).
|
||||
- Not all of our code is up to our coding conventions. In particular,
|
||||
the `library API
|
||||
documentation <https://beets.readthedocs.io/en/stable/dev/library.html>`__
|
||||
are currently quite sparse. You can help by adding to the docstrings
|
||||
in the code and to the documentation pages themselves. beets follows
|
||||
`PEP-257 <https://www.python.org/dev/peps/pep-0257/>`__ for
|
||||
docstrings and in some places, we also sometimes use `ReST autodoc
|
||||
syntax for
|
||||
Sphinx <https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html>`__
|
||||
to, for example, refer to a class name.
|
||||
|
||||
.. _poethepoet: https://poethepoet.natn.io/index.html
|
||||
.. _poetry: https://python-poetry.org/docs/
|
||||
- We maintain a set of `issues marked as “good first issue”
|
||||
<https://github.com/beetbox/beets/labels/good%20first%20issue>`__. These are
|
||||
issues that would serve as a good introduction to the codebase. Claim one and
|
||||
start exploring!
|
||||
- Like testing? Our `test coverage <https://codecov.io/github/beetbox/beets>`__
|
||||
is somewhat low. You can help out by finding low-coverage modules or checking
|
||||
out other `testing-related issues
|
||||
<https://github.com/beetbox/beets/labels/testing>`__.
|
||||
- There are several ways to improve the tests in general (see :ref:`testing` and
|
||||
some places to think about performance optimization (see `Optimization
|
||||
<https://github.com/beetbox/beets/wiki/Optimization>`__).
|
||||
- Not all of our code is up to our coding conventions. In particular, the
|
||||
`library API documentation
|
||||
<https://beets.readthedocs.io/en/stable/dev/library.html>`__ are currently
|
||||
quite sparse. You can help by adding to the docstrings in the code and to the
|
||||
documentation pages themselves. beets follows `PEP-257
|
||||
<https://www.python.org/dev/peps/pep-0257/>`__ for docstrings and in some
|
||||
places, we also sometimes use `ReST autodoc syntax for Sphinx
|
||||
<https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html>`__ to,
|
||||
for example, refer to a class name.
|
||||
|
||||
Your First Contribution
|
||||
=======================
|
||||
-----------------------
|
||||
|
||||
If this is your first time contributing to an open source project,
|
||||
welcome! If you are confused at all about how to contribute or what to
|
||||
contribute, take a look at `this great
|
||||
tutorial <http://makeapullrequest.com/>`__, or stop by our
|
||||
`discussion board <https://github.com/beetbox/beets/discussions/>`__
|
||||
if you have any questions.
|
||||
If this is your first time contributing to an open source project, welcome! If
|
||||
you are confused at all about how to contribute or what to contribute, take a
|
||||
look at `this great tutorial <http://makeapullrequest.com/>`__, or stop by our
|
||||
`discussion board`_ if you have any questions.
|
||||
|
||||
We maintain a list of issues we reserved for those new to open source
|
||||
labeled `“first timers
|
||||
only” <https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22>`__.
|
||||
Since the goal of these issues is to get users comfortable with
|
||||
contributing to an open source project, please do not hesitate to ask
|
||||
any questions.
|
||||
We maintain a list of issues we reserved for those new to open source labeled
|
||||
`first timers only`_. Since the goal of these issues is to get users comfortable
|
||||
with contributing to an open source project, please do not hesitate to ask any
|
||||
questions.
|
||||
|
||||
.. _first timers only: https://github.com/beetbox/beets/issues?q=is%3Aopen+is%3Aissue+label%3A%22first+timers+only%22
|
||||
|
||||
How to Submit Your Work
|
||||
=======================
|
||||
-----------------------
|
||||
|
||||
Do you have a great bug fix, new feature, or documentation expansion
|
||||
you’d like to contribute? Follow these steps to create a GitHub pull
|
||||
request and your code will ship in no time.
|
||||
Do you have a great bug fix, new feature, or documentation expansion you’d like
|
||||
to contribute? Follow these steps to create a GitHub pull request and your code
|
||||
will ship in no time.
|
||||
|
||||
1. Fork the beets repository and clone it (see above) to create a
|
||||
workspace.
|
||||
1. Fork the beets repository and clone it (see above) to create a workspace.
|
||||
2. Install pre-commit, following the instructions `here
|
||||
<https://pre-commit.com/>`_.
|
||||
3. Make your changes.
|
||||
4. Add tests. If you’ve fixed a bug, write a test to ensure that you’ve
|
||||
actually fixed it. If there’s a new feature or plugin, please
|
||||
contribute tests that show that your code does what it says.
|
||||
5. Add documentation. If you’ve added a new command flag, for example,
|
||||
find the appropriate page under ``docs/`` where it needs to be
|
||||
listed.
|
||||
6. Add a changelog entry to ``docs/changelog.rst`` near the top of the
|
||||
document.
|
||||
4. Add tests. If you’ve fixed a bug, write a test to ensure that you’ve actually
|
||||
fixed it. If there’s a new feature or plugin, please contribute tests that
|
||||
show that your code does what it says.
|
||||
5. Add documentation. If you’ve added a new command flag, for example, find the
|
||||
appropriate page under ``docs/`` where it needs to be listed.
|
||||
6. Add a changelog entry to ``docs/changelog.rst`` near the top of the document.
|
||||
7. Run the tests and style checker, see :ref:`testing`.
|
||||
8. Push to your fork and open a pull request! We’ll be in touch shortly.
|
||||
9. If you add commits to a pull request, please add a comment or
|
||||
re-request a review after you push them since GitHub doesn’t
|
||||
automatically notify us when commits are added.
|
||||
9. If you add commits to a pull request, please add a comment or re-request a
|
||||
review after you push them since GitHub doesn’t automatically notify us when
|
||||
commits are added.
|
||||
|
||||
Remember, code contributions have four parts: the code, the tests, the
|
||||
documentation, and the changelog entry. Thank you for contributing!
|
||||
|
||||
The Code
|
||||
========
|
||||
.. admonition:: Ownership
|
||||
|
||||
The documentation has a section on the
|
||||
`library API <https://beets.readthedocs.io/en/stable/dev/library.html>`__
|
||||
that serves as an introduction to beets’ design.
|
||||
If you are the owner of a plugin, please consider reviewing pull requests
|
||||
that affect your plugin. If you are not the owner of a plugin, please
|
||||
consider becoming one! You can do so by adding an entry to
|
||||
``.github/CODEOWNERS``. This way, you will automatically receive a review
|
||||
request for pull requests that adjust the code that you own. If you have any
|
||||
questions, please ask on our `discussion board`_.
|
||||
|
||||
The Code
|
||||
--------
|
||||
|
||||
The documentation has a section on the `library API
|
||||
<https://beets.readthedocs.io/en/stable/dev/library.html>`__ that serves as an
|
||||
introduction to beets’ design.
|
||||
|
||||
Coding Conventions
|
||||
==================
|
||||
------------------
|
||||
|
||||
General
|
||||
-------
|
||||
~~~~~~~
|
||||
|
||||
There are a few coding conventions we use in beets:
|
||||
|
||||
- Whenever you access the library database, do so through the provided
|
||||
Library methods or via a Transaction object. Never call
|
||||
``lib.conn.*`` directly. For example, do this:
|
||||
- Whenever you access the library database, do so through the provided Library
|
||||
methods or via a Transaction object. Never call ``lib.conn.*`` directly. For
|
||||
example, do this:
|
||||
|
||||
.. code-block:: python
|
||||
.. code-block:: python
|
||||
|
||||
with g.lib.transaction() as tx:
|
||||
rows = tx.query("SELECT DISTINCT '{0}' FROM '{1}' ORDER BY '{2}'"
|
||||
.format(field, model._table, sort_field))
|
||||
with g.lib.transaction() as tx:
|
||||
rows = tx.query("SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}")
|
||||
|
||||
To fetch Item objects from the database, use lib.items(…) and supply
|
||||
a query as an argument. Resist the urge to write raw SQL for your
|
||||
query. If you must use lower-level queries into the database, do
|
||||
this:
|
||||
To fetch Item objects from the database, use lib.items(…) and supply a query
|
||||
as an argument. Resist the urge to write raw SQL for your query. If you must
|
||||
use lower-level queries into the database, do this, for example:
|
||||
|
||||
.. code-block:: python
|
||||
.. code-block:: python
|
||||
|
||||
with lib.transaction() as tx:
|
||||
rows = tx.query("SELECT …")
|
||||
with lib.transaction() as tx:
|
||||
rows = tx.query("SELECT path FROM items WHERE album_id = ?", (album_id,))
|
||||
|
||||
Transaction objects help control concurrent access to the database
|
||||
and assist in debugging conflicting accesses.
|
||||
- ``str.format()`` should be used instead of the ``%`` operator
|
||||
- Never ``print`` informational messages; use the
|
||||
`logging <http://docs.python.org/library/logging.html>`__ module
|
||||
instead. In particular, we have our own logging shim, so you’ll see
|
||||
``from beets import logging`` in most files.
|
||||
Transaction objects help control concurrent access to the database and assist
|
||||
in debugging conflicting accesses.
|
||||
|
||||
- The loggers use
|
||||
`str.format <http://docs.python.org/library/stdtypes.html#str.format>`__-style
|
||||
logging instead of ``%``-style, so you can type
|
||||
``log.debug("{0}", obj)`` to do your formatting.
|
||||
- f-strings should be used instead of the ``%`` operator and ``str.format()``
|
||||
calls.
|
||||
- Never ``print`` informational messages; use the `logging
|
||||
<http://docs.python.org/library/logging.html>`__ module instead. In
|
||||
particular, we have our own logging shim, so you’ll see ``from beets import
|
||||
logging`` in most files.
|
||||
|
||||
- Exception handlers must use ``except A as B:`` instead of
|
||||
``except A, B:``.
|
||||
- The loggers use `str.format
|
||||
<http://docs.python.org/library/stdtypes.html#str.format>`__-style logging
|
||||
instead of ``%``-style, so you can type ``log.debug("{}", obj)`` to do your
|
||||
formatting.
|
||||
|
||||
- Exception handlers must use ``except A as B:`` instead of ``except A, B:``.
|
||||
|
||||
Style
|
||||
-----
|
||||
~~~~~
|
||||
|
||||
We use `ruff`_ to format and lint the codebase.
|
||||
We use `ruff <https://docs.astral.sh/ruff/>`__ to format and lint the codebase.
|
||||
|
||||
Run ``poe check-format`` and ``poe lint`` to check your code for style and
|
||||
linting errors. Running ``poe format`` will automatically format your code
|
||||
according to the specifications required by the project.
|
||||
|
||||
.. _ruff: https://docs.astral.sh/ruff/
|
||||
|
||||
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.
|
||||
Similarly, run ``poe format-docs`` and ``poe lint-docs`` to ensure consistent
|
||||
documentation formatting and check for any issues.
|
||||
|
||||
Editor Settings
|
||||
---------------
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
Personally, I work on beets with `vim`_. Here are
|
||||
some ``.vimrc`` lines that might help with PEP 8-compliant Python
|
||||
coding::
|
||||
Personally, I work on beets with vim_. Here are some ``.vimrc`` lines that might
|
||||
help with PEP 8-compliant Python coding:
|
||||
|
||||
::
|
||||
|
||||
filetype indent on
|
||||
autocmd FileType python setlocal shiftwidth=4 tabstop=4 softtabstop=4 expandtab shiftround autoindent
|
||||
|
||||
Consider installing `this alternative Python indentation
|
||||
plugin <https://github.com/mitsuhiko/vim-python-combined>`__. I also
|
||||
like `neomake <https://github.com/neomake/neomake>`__ with its flake8
|
||||
checker.
|
||||
Consider installing `this alternative Python indentation plugin
|
||||
<https://github.com/mitsuhiko/vim-python-combined>`__. I also like `neomake
|
||||
<https://github.com/neomake/neomake>`__ with its flake8 checker.
|
||||
|
||||
.. _testing:
|
||||
|
||||
Testing
|
||||
=======
|
||||
-------
|
||||
|
||||
Running the Tests
|
||||
-----------------
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
Use ``poe`` to run tests::
|
||||
Use ``poe`` to run tests:
|
||||
|
||||
::
|
||||
|
||||
$ poe test [pytest options]
|
||||
|
||||
You can disable a hand-selected set of "slow" tests by setting the
|
||||
environment variable ``SKIP_SLOW_TESTS``, for example::
|
||||
You can disable a hand-selected set of "slow" tests by setting the environment
|
||||
variable ``SKIP_SLOW_TESTS``, for example:
|
||||
|
||||
::
|
||||
|
||||
$ SKIP_SLOW_TESTS=1 poe test
|
||||
|
||||
Coverage
|
||||
^^^^^^^^
|
||||
++++++++
|
||||
|
||||
The ``test`` command does not include coverage as it slows down testing. In
|
||||
order to measure it, use the ``test-with-coverage`` task
|
||||
|
|
@ -350,56 +336,71 @@ You are welcome to explore coverage by opening the HTML report in
|
|||
Note that for each covered line the report shows **which tests cover it**
|
||||
(expand the list on the right-hand side of the affected line).
|
||||
|
||||
You can find project coverage status on `Codecov`_.
|
||||
You can find project coverage status on Codecov_.
|
||||
|
||||
Red Flags
|
||||
^^^^^^^^^
|
||||
+++++++++
|
||||
|
||||
The `pytest-random`_ plugin makes it easy to randomize the order of
|
||||
tests. ``poe test --random`` will occasionally turn up failing tests
|
||||
that reveal ordering dependencies—which are bad news!
|
||||
The pytest-random_ plugin makes it easy to randomize the order of tests. ``poe
|
||||
test --random`` will occasionally turn up failing tests that reveal ordering
|
||||
dependencies—which are bad news!
|
||||
|
||||
Test Dependencies
|
||||
^^^^^^^^^^^^^^^^^
|
||||
+++++++++++++++++
|
||||
|
||||
The tests have a few more dependencies than beets itself. (The additional
|
||||
dependencies consist of testing utilities and dependencies of non-default
|
||||
plugins exercised by the test suite.) The dependencies are listed under the
|
||||
``tool.poetry.group.test.dependencies`` section in `pyproject.toml`_.
|
||||
``tool.poetry.group.test.dependencies`` section in pyproject.toml_.
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Writing tests is done by adding or modifying files in folder `test`_.
|
||||
Take a look at
|
||||
`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`_
|
||||
to get a basic view on how tests are written. Since we are currently migrating
|
||||
the tests from `unittest`_ to `pytest`_, new tests should be written using
|
||||
`pytest`_. Contributions migrating existing tests are welcome!
|
||||
Writing tests is done by adding or modifying files in folder test_. Take a look
|
||||
at `https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`_ to
|
||||
get a basic view on how tests are written. Since we are currently migrating the
|
||||
tests from unittest_ to pytest_, new tests should be written using pytest_.
|
||||
Contributions migrating existing tests are welcome!
|
||||
|
||||
External API requests under test should be mocked with `requests-mock`_,
|
||||
However, we still want to know whether external APIs are up and that they
|
||||
return expected responses, therefore we test them weekly with our `integration
|
||||
test`_ suite.
|
||||
External API requests under test should be mocked with requests-mock_, However,
|
||||
we still want to know whether external APIs are up and that they return expected
|
||||
responses, therefore we test them weekly with our `integration test`_ suite.
|
||||
|
||||
In order to add such a test, mark your test with the ``integration_test`` marker
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@pytest.mark.integration_test
|
||||
def test_external_api_call():
|
||||
...
|
||||
@pytest.mark.integration_test
|
||||
def test_external_api_call(): ...
|
||||
|
||||
This way, the test will be run only in the integration test suite.
|
||||
|
||||
.. _Codecov: https://codecov.io/github/beetbox/beets
|
||||
.. _pytest-random: https://github.com/klrmn/pytest-random
|
||||
.. _pytest: https://docs.pytest.org/en/stable/
|
||||
.. _pyproject.toml: https://github.com/beetbox/beets/tree/master/pyproject.toml
|
||||
.. _test: https://github.com/beetbox/beets/tree/master/test
|
||||
.. _`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
|
||||
.. _unittest: https://docs.python.org/3/library/unittest.html
|
||||
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
|
||||
.. _requests-mock: https://requests-mock.readthedocs.io/en/latest/response.html
|
||||
.. _codecov: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. _discussion board: https://github.com/beetbox/beets/discussions
|
||||
|
||||
.. _documentation: https://beets.readthedocs.io/en/stable/
|
||||
|
||||
.. _https://github.com/beetbox/beets/blob/master/test/test_template.py#l224: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224
|
||||
|
||||
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
|
||||
|
||||
.. _pipx: https://pipx.pypa.io/stable
|
||||
|
||||
.. _poethepoet: https://poethepoet.natn.io/index.html
|
||||
|
||||
.. _poetry: https://python-poetry.org/docs/
|
||||
|
||||
.. _pyproject.toml: https://github.com/beetbox/beets/tree/master/pyproject.toml
|
||||
|
||||
.. _pytest: https://docs.pytest.org/en/stable/
|
||||
|
||||
.. _pytest-random: https://github.com/klrmn/pytest-random
|
||||
|
||||
.. _requests-mock: https://requests-mock.readthedocs.io/en/latest/response.html
|
||||
|
||||
.. _test: https://github.com/beetbox/beets/tree/master/test
|
||||
|
||||
.. _unittest: https://docs.python.org/3/library/unittest.html
|
||||
|
||||
.. _vim: https://www.vim.org/
|
||||
|
|
|
|||
149
README.rst
149
README.rst
|
|
@ -10,115 +10,132 @@
|
|||
.. image:: https://repology.org/badge/tiny-repos/beets.svg
|
||||
:target: https://repology.org/project/beets/versions
|
||||
|
||||
|
||||
beets
|
||||
=====
|
||||
|
||||
Beets is the media library management system for obsessive music geeks.
|
||||
|
||||
The purpose of beets is to get your music collection right once and for all.
|
||||
It catalogs your collection, automatically improving its metadata as it goes.
|
||||
It then provides a bouquet of tools for manipulating and accessing your music.
|
||||
The purpose of beets is to get your music collection right once and for all. It
|
||||
catalogs your collection, automatically improving its metadata as it goes. It
|
||||
then provides a suite of tools for manipulating and accessing your music.
|
||||
|
||||
Here's an example of beets' brainy tag corrector doing its thing::
|
||||
Here's an example of beets' brainy tag corrector doing its thing:
|
||||
|
||||
$ beet import ~/music/ladytron
|
||||
Tagging:
|
||||
Ladytron - Witching Hour
|
||||
(Similarity: 98.4%)
|
||||
* Last One Standing -> The Last One Standing
|
||||
* Beauty -> Beauty*2
|
||||
* White Light Generation -> Whitelightgenerator
|
||||
* All the Way -> All the Way...
|
||||
::
|
||||
|
||||
$ beet import ~/music/ladytron
|
||||
Tagging:
|
||||
Ladytron - Witching Hour
|
||||
(Similarity: 98.4%)
|
||||
* Last One Standing -> The Last One Standing
|
||||
* Beauty -> Beauty*2
|
||||
* White Light Generation -> Whitelightgenerator
|
||||
* All the Way -> All the Way...
|
||||
|
||||
Because beets is designed as a library, it can do almost anything you can
|
||||
imagine for your music collection. Via `plugins`_, beets becomes a panacea:
|
||||
imagine for your music collection. Via plugins_, beets becomes a panacea:
|
||||
|
||||
- Fetch or calculate all the metadata you could possibly need: `album art`_,
|
||||
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
|
||||
fingerprints`_.
|
||||
- Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess
|
||||
metadata using songs' filenames or their acoustic fingerprints.
|
||||
lyrics_, genres_, tempos_, ReplayGain_ levels, or `acoustic fingerprints`_.
|
||||
- Get metadata from MusicBrainz_, Discogs_, and Beatport_. Or guess metadata
|
||||
using songs' filenames or their acoustic fingerprints.
|
||||
- `Transcode audio`_ to any format you like.
|
||||
- Check your library for `duplicate tracks and albums`_ or for `albums that
|
||||
are missing tracks`_.
|
||||
- Check your library for `duplicate tracks and albums`_ or for `albums that are
|
||||
missing tracks`_.
|
||||
- Clean up crufty tags left behind by other, less-awesome tools.
|
||||
- Embed and extract album art from files' metadata.
|
||||
- Browse your music library graphically through a Web browser and play it in any
|
||||
browser that supports `HTML5 Audio`_.
|
||||
- Analyze music files' metadata from the command line.
|
||||
- Listen to your library with a music player that speaks the `MPD`_ protocol
|
||||
and works with a staggering variety of interfaces.
|
||||
- Listen to your library with a music player that speaks the MPD_ protocol and
|
||||
works with a staggering variety of interfaces.
|
||||
|
||||
If beets doesn't do what you want yet, `writing your own plugin`_ is
|
||||
shockingly simple if you know a little Python.
|
||||
If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly
|
||||
simple if you know a little Python.
|
||||
|
||||
.. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
|
||||
.. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html
|
||||
|
||||
.. _beatport: https://www.beatport.com
|
||||
|
||||
.. _discogs: https://www.discogs.com/
|
||||
|
||||
.. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
|
||||
.. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element
|
||||
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
|
||||
.. _mpd: https://www.musicpd.org/
|
||||
|
||||
.. _musicbrainz: https://musicbrainz.org/
|
||||
|
||||
.. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
|
||||
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
.. _writing your own plugin:
|
||||
https://beets.readthedocs.org/page/dev/plugins.html
|
||||
.. _HTML5 Audio:
|
||||
https://html.spec.whatwg.org/multipage/media.html#the-audio-element
|
||||
.. _albums that are missing tracks:
|
||||
https://beets.readthedocs.org/page/plugins/missing.html
|
||||
.. _duplicate tracks and albums:
|
||||
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
.. _Transcode audio:
|
||||
https://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: https://www.discogs.com/
|
||||
.. _acoustic fingerprints:
|
||||
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
|
||||
.. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
|
||||
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: https://musicbrainz.org/
|
||||
.. _Beatport: https://www.beatport.com
|
||||
|
||||
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
|
||||
|
||||
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
You can install beets by typing ``pip install beets`` or directly from Github (see details `here`_).
|
||||
Beets has also been packaged in the `software repositories`_ of several
|
||||
distributions. Check out the `Getting Started`_ guide for more information.
|
||||
You can install beets by typing ``pip install beets`` or directly from Github
|
||||
(see details here_). Beets has also been packaged in the `software
|
||||
repositories`_ of several distributions. Check out the `Getting Started`_ guide
|
||||
for more information.
|
||||
|
||||
.. _getting started: https://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
.. _here: https://beets.readthedocs.io/en/latest/faq.html#run-the-latest-source-version-of-beets
|
||||
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
.. _software repositories: https://repology.org/project/beets/versions
|
||||
|
||||
Contribute
|
||||
----------
|
||||
|
||||
Thank you for considering contributing to ``beets``! Whether you're a
|
||||
programmer or not, you should be able to find all the info you need at
|
||||
`CONTRIBUTING.rst`_.
|
||||
Thank you for considering contributing to ``beets``! Whether you're a programmer
|
||||
or not, you should be able to find all the info you need at CONTRIBUTING.rst_.
|
||||
|
||||
.. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
|
||||
.. _contributing.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
||||
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Mastodon for
|
||||
news and updates.
|
||||
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Mastodon for news
|
||||
and updates.
|
||||
|
||||
.. _its Web site: https://beets.io/
|
||||
.. _@b33ts: https://fosstodon.org/@beets
|
||||
|
||||
.. _its web site: https://beets.io/
|
||||
|
||||
Contact
|
||||
-------
|
||||
* Encountered a bug you'd like to report? Check out our `issue tracker`_!
|
||||
* If your issue hasn't already been reported, please `open a new ticket`_
|
||||
and we'll be in touch with you shortly.
|
||||
* If you'd like to vote on a feature/bug, simply give a :+1: on issues
|
||||
you'd like to see prioritized over others.
|
||||
* Need help/support, would like to start a discussion, have an idea for a new
|
||||
feature, or would just like to introduce yourself to the team? Check out
|
||||
`GitHub Discussions`_!
|
||||
|
||||
.. _GitHub Discussions: https://github.com/beetbox/beets/discussions
|
||||
- Encountered a bug you'd like to report? Check out our `issue tracker`_!
|
||||
|
||||
- If your issue hasn't already been reported, please `open a new ticket`_ and
|
||||
we'll be in touch with you shortly.
|
||||
- If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd
|
||||
like to see prioritized over others.
|
||||
- Need help/support, would like to start a discussion, have an idea for a new
|
||||
feature, or would just like to introduce yourself to the team? Check out
|
||||
`GitHub Discussions`_!
|
||||
|
||||
.. _github discussions: https://github.com/beetbox/beets/discussions
|
||||
|
||||
.. _issue tracker: https://github.com/beetbox/beets/issues
|
||||
|
||||
.. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose
|
||||
|
||||
Authors
|
||||
|
|
@ -126,4 +143,4 @@ Authors
|
|||
|
||||
Beets is by `Adrian Sampson`_ with a supporting cast of thousands.
|
||||
|
||||
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||
.. _adrian sampson: https://www.cs.cornell.edu/~asampson/
|
||||
|
|
|
|||
227
README_kr.rst
227
README_kr.rst
|
|
@ -1,108 +1,119 @@
|
|||
.. image:: https://img.shields.io/pypi/v/beets.svg
|
||||
:target: https://pypi.python.org/pypi/beets
|
||||
|
||||
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
:target: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||
:target: https://travis-ci.org/beetbox/beets
|
||||
|
||||
|
||||
beets
|
||||
=====
|
||||
|
||||
Beets는 강박적인 음악을 듣는 사람들을 위한 미디어 라이브러리 관리 시스템이다.
|
||||
|
||||
Beets의 목적은 음악들을 한번에 다 받는 것이다.
|
||||
음악들을 카탈로그화 하고, 자동으로 메타 데이터를 개선한다.
|
||||
그리고 음악에 접근하고 조작할 수 있는 도구들을 제공한다.
|
||||
|
||||
다음은 Beets의 brainy tag corrector가 한 일의 예시이다.
|
||||
|
||||
$ beet import ~/music/ladytron
|
||||
Tagging:
|
||||
Ladytron - Witching Hour
|
||||
(Similarity: 98.4%)
|
||||
* Last One Standing -> The Last One Standing
|
||||
* Beauty -> Beauty*2
|
||||
* White Light Generation -> Whitelightgenerator
|
||||
* All the Way -> All the Way...
|
||||
|
||||
Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들에 대해 상상하는 모든 것을 할 수 있다.
|
||||
`plugins`_ 을 통해서 모든 것을 할 수 있는 것이다!
|
||||
|
||||
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_,
|
||||
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
|
||||
fingerprints`_.
|
||||
- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
|
||||
노래 제목이나 음향 특징으로 메타데이터를 추측한다
|
||||
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
|
||||
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다.
|
||||
- 남이 남기거나, 좋지 않은 도구로 남긴 잡다한 태그들을 지운다.
|
||||
- 파일의 메타데이터에서 앨범 아트를 삽입이나 추출한다.
|
||||
- 당신의 음악들을 `HTML5 Audio`_ 를 지원하는 어떤 브라우저든 재생할 수 있고,
|
||||
웹 브라우저에 표시 할 수 있다.
|
||||
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
|
||||
- `MPD`_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
|
||||
|
||||
만약 Beets에 당신이 원하는게 아직 없다면,
|
||||
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
|
||||
|
||||
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
.. _writing your own plugin:
|
||||
https://beets.readthedocs.org/page/dev/plugins.html
|
||||
.. _HTML5 Audio:
|
||||
https://html.spec.whatwg.org/multipage/media.html#the-audio-element
|
||||
.. _albums that are missing tracks:
|
||||
https://beets.readthedocs.org/page/plugins/missing.html
|
||||
.. _duplicate tracks and albums:
|
||||
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
.. _Transcode audio:
|
||||
https://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: https://www.discogs.com/
|
||||
.. _acoustic fingerprints:
|
||||
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: https://musicbrainz.org/
|
||||
.. _Beatport: https://www.beatport.com
|
||||
|
||||
설치
|
||||
-------
|
||||
|
||||
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
|
||||
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
|
||||
|
||||
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
컨트리뷰션
|
||||
----------
|
||||
|
||||
어떻게 도우려는지 알고싶다면 `Hacking`_ 위키페이지를 확인하라.
|
||||
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
|
||||
|
||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
||||
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
|
||||
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
|
||||
|
||||
.. _its Web site: https://beets.io/
|
||||
.. _@b33ts: https://twitter.com/b33ts/
|
||||
|
||||
저자들
|
||||
-------
|
||||
|
||||
`Adrian Sampson`_ 와 많은 사람들의 지지를 받아 Beets를 만들었다.
|
||||
돕고 싶다면 `forum`_.를 방문하면 된다.
|
||||
|
||||
.. _forum: https://github.com/beetbox/beets/discussions/
|
||||
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||
.. image:: https://img.shields.io/pypi/v/beets.svg
|
||||
:target: https://pypi.python.org/pypi/beets
|
||||
|
||||
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
:target: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||
:target: https://travis-ci.org/beetbox/beets
|
||||
|
||||
beets
|
||||
=====
|
||||
|
||||
Beets는 강박적인 음악을 듣는 사람들을 위한 미디어 라이브러리 관리 시스템이다.
|
||||
|
||||
Beets의 목적은 음악들을 한번에 다 받는 것이다. 음악들을 카탈로그화 하고, 자동으로 메타 데이터를 개선한다. 그리고 음악에 접근하고 조작할
|
||||
수 있는 도구들을 제공한다.
|
||||
|
||||
다음은 Beets의 brainy tag corrector가 한 일의 예시이다.
|
||||
|
||||
::
|
||||
|
||||
$ beet import ~/music/ladytron
|
||||
Tagging:
|
||||
Ladytron - Witching Hour
|
||||
(Similarity: 98.4%)
|
||||
* Last One Standing -> The Last One Standing
|
||||
* Beauty -> Beauty*2
|
||||
* White Light Generation -> Whitelightgenerator
|
||||
* All the Way -> All the Way...
|
||||
|
||||
Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들에 대해 상상하는 모든 것을 할 수 있다. plugins_ 을 통해서 모든 것을 할
|
||||
수 있는 것이다!
|
||||
|
||||
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_, lyrics_, genres_, tempos_,
|
||||
ReplayGain_ levels, or `acoustic fingerprints`_.
|
||||
- MusicBrainz_, Discogs_,`Beatport`_로부터 메타데이터를 가져오거나, 노래 제목이나 음향 특징으로 메타데이터를
|
||||
추측한다
|
||||
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
|
||||
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing
|
||||
tracks`_ 를 검사한다.
|
||||
- 남이 남기거나, 좋지 않은 도구로 남긴 잡다한 태그들을 지운다.
|
||||
- 파일의 메타데이터에서 앨범 아트를 삽입이나 추출한다.
|
||||
- 당신의 음악들을 `HTML5 Audio`_ 를 지원하는 어떤 브라우저든 재생할 수 있고, 웹 브라우저에 표시 할 수 있다.
|
||||
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
|
||||
- MPD_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
|
||||
|
||||
만약 Beets에 당신이 원하는게 아직 없다면, 당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로
|
||||
간단하다.
|
||||
|
||||
.. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
|
||||
.. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html
|
||||
|
||||
.. _beatport: https://www.beatport.com
|
||||
|
||||
.. _discogs: https://www.discogs.com/
|
||||
|
||||
.. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
|
||||
.. _html5 audio: https://html.spec.whatwg.org/multipage/media.html#the-audio-element
|
||||
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
|
||||
.. _mpd: https://www.musicpd.org/
|
||||
|
||||
.. _musicbrainz: https://musicbrainz.org/
|
||||
|
||||
.. _musicbrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
|
||||
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||
|
||||
.. _replaygain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
|
||||
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
|
||||
.. _transcode audio: https://beets.readthedocs.org/page/plugins/convert.html
|
||||
|
||||
.. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins/index.html
|
||||
|
||||
설치
|
||||
-------
|
||||
|
||||
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다. 그리고 `Getting Started`_ 가이드를
|
||||
확인할 수 있다.
|
||||
|
||||
.. _getting started: https://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
컨트리뷰션
|
||||
----------
|
||||
|
||||
어떻게 도우려는지 알고싶다면 Hacking_ 위키페이지를 확인하라. 당신은 docs 안에 `For Developers`_ 에도 관심이 있을수
|
||||
있다.
|
||||
|
||||
.. _for developers: https://beets.readthedocs.io/en/stable/dev/
|
||||
|
||||
.. _hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
||||
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다. 트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수
|
||||
있다.
|
||||
|
||||
.. _@b33ts: https://twitter.com/b33ts/
|
||||
|
||||
.. _its web site: https://beets.io/
|
||||
|
||||
저자들
|
||||
-------
|
||||
|
||||
`Adrian Sampson`_ 와 많은 사람들의 지지를 받아 Beets를 만들었다. 돕고 싶다면 forum_.를 방문하면 된다.
|
||||
|
||||
.. _adrian sampson: https://www.cs.cornell.edu/~asampson/
|
||||
|
||||
.. _forum: https://github.com/beetbox/beets/discussions/
|
||||
|
|
|
|||
|
|
@ -17,10 +17,21 @@ from sys import stderr
|
|||
|
||||
import confuse
|
||||
|
||||
__version__ = "2.2.0"
|
||||
from .util.deprecation import deprecate_imports
|
||||
|
||||
__version__ = "2.5.1"
|
||||
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Handle deprecated imports."""
|
||||
return deprecate_imports(
|
||||
__name__,
|
||||
{"art": "beetsplug._utils", "vfs": "beetsplug._utils"},
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
class IncludeLazyConfig(confuse.LazyConfig):
|
||||
"""A version of Confuse's LazyConfig that also merges in data from
|
||||
YAML files specified in an `include` setting.
|
||||
|
|
@ -35,7 +46,7 @@ class IncludeLazyConfig(confuse.LazyConfig):
|
|||
except confuse.NotFoundError:
|
||||
pass
|
||||
except confuse.ConfigReadError as err:
|
||||
stderr.write("configuration `import` failed: {}".format(err.reason))
|
||||
stderr.write(f"configuration `import` failed: {err.reason}")
|
||||
|
||||
|
||||
config = IncludeLazyConfig("beets", __name__)
|
||||
|
|
|
|||
|
|
@ -14,36 +14,48 @@
|
|||
|
||||
"""Facilities for automatically determining files' correct metadata."""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Union
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import config, logging
|
||||
from beets.library import Album, Item, LibModel
|
||||
|
||||
# Parts of external interface.
|
||||
from beets.util import unique_list
|
||||
from beets.util.deprecation import deprecate_for_maintainers, deprecate_imports
|
||||
|
||||
from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
|
||||
from .match import Proposal, Recommendation, tag_album, tag_item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from beets.library import Album, Item, LibModel
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "current_metadata":
|
||||
deprecate_for_maintainers(
|
||||
f"'beets.autotag.{name}'", "'beets.util.get_most_common_tags'"
|
||||
)
|
||||
return import_module("beets.util").get_most_common_tags
|
||||
|
||||
return deprecate_imports(
|
||||
__name__, {"Distance": "beets.autotag.distance"}, name
|
||||
)
|
||||
|
||||
from .hooks import AlbumInfo, AlbumMatch, Distance, TrackInfo, TrackMatch
|
||||
from .match import (
|
||||
Proposal,
|
||||
Recommendation,
|
||||
current_metadata,
|
||||
tag_album,
|
||||
tag_item,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlbumInfo",
|
||||
"AlbumMatch",
|
||||
"Distance",
|
||||
"TrackInfo",
|
||||
"TrackMatch",
|
||||
"Proposal",
|
||||
"Recommendation",
|
||||
"TrackInfo",
|
||||
"TrackMatch",
|
||||
"apply_album_metadata",
|
||||
"apply_item_metadata",
|
||||
"apply_metadata",
|
||||
"current_metadata",
|
||||
"tag_album",
|
||||
"tag_item",
|
||||
]
|
||||
|
|
@ -99,8 +111,8 @@ SPECIAL_FIELDS = {
|
|||
|
||||
|
||||
def _apply_metadata(
|
||||
info: Union[AlbumInfo, TrackInfo],
|
||||
db_obj: Union[Album, Item],
|
||||
info: AlbumInfo | TrackInfo,
|
||||
db_obj: Album | Item,
|
||||
nullable_fields: Sequence[str] = [],
|
||||
):
|
||||
"""Set the db_obj's metadata to match the info."""
|
||||
|
|
@ -192,11 +204,11 @@ def apply_album_metadata(album_info: AlbumInfo, album: Album):
|
|||
correct_list_fields(album)
|
||||
|
||||
|
||||
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
|
||||
"""Set the items' metadata to match an AlbumInfo object using a
|
||||
mapping from Items to TrackInfo objects.
|
||||
"""
|
||||
for item, track_info in mapping.items():
|
||||
def apply_metadata(
|
||||
album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]]
|
||||
):
|
||||
"""Set items metadata to match corresponding tagged info."""
|
||||
for item, track_info in item_info_pairs:
|
||||
# Artist or artist credit.
|
||||
if config["artist_credit"]:
|
||||
item.artist = (
|
||||
|
|
@ -243,7 +255,7 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
|
|||
continue
|
||||
|
||||
for suffix in "year", "month", "day":
|
||||
key = prefix + suffix
|
||||
key = f"{prefix}{suffix}"
|
||||
value = getattr(album_info, key) or 0
|
||||
|
||||
# If we don't even have a year, apply nothing.
|
||||
|
|
|
|||
535
beets/autotag/distance.py
Normal file
535
beets/autotag/distance.py
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from functools import cache, total_ordering
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jellyfish import levenshtein_distance
|
||||
from unidecode import unidecode
|
||||
|
||||
from beets import config, metadata_plugins
|
||||
from beets.util import as_string, cached_classproperty, get_most_common_tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
from beets.library import Item
|
||||
|
||||
from .hooks import AlbumInfo, TrackInfo
|
||||
|
||||
# Candidate distance scoring.
|
||||
|
||||
# Artist signals that indicate "various artists". These are used at the
|
||||
# album level to determine whether a given release is likely a VA
|
||||
# release and also on the track level to to remove the penalty for
|
||||
# differing artists.
|
||||
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
|
||||
|
||||
# Parameters for string distance function.
|
||||
# Words that can be moved to the end of a string using a comma.
|
||||
SD_END_WORDS = ["the", "a", "an"]
|
||||
# Reduced weights for certain portions of the string.
|
||||
SD_PATTERNS = [
|
||||
(r"^the ", 0.1),
|
||||
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
|
||||
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
|
||||
(r"\(.*?\)", 0.3),
|
||||
(r"\[.*?\]", 0.3),
|
||||
(r"(, )?(pt\.|part) .+", 0.2),
|
||||
]
|
||||
# Replacements to use before testing distance.
|
||||
SD_REPLACE = [
|
||||
(r"&", "and"),
|
||||
]
|
||||
|
||||
|
||||
def _string_dist_basic(str1: str, str2: str) -> float:
|
||||
"""Basic edit distance between two strings, ignoring
|
||||
non-alphanumeric characters and case. Comparisons are based on a
|
||||
transliteration/lowering to ASCII characters. Normalized by string
|
||||
length.
|
||||
"""
|
||||
assert isinstance(str1, str)
|
||||
assert isinstance(str2, str)
|
||||
str1 = as_string(unidecode(str1))
|
||||
str2 = as_string(unidecode(str2))
|
||||
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
|
||||
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
|
||||
if not str1 and not str2:
|
||||
return 0.0
|
||||
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
|
||||
|
||||
|
||||
def string_dist(str1: str | None, str2: str | None) -> float:
|
||||
"""Gives an "intuitive" edit distance between two strings. This is
|
||||
an edit distance, normalized by the string length, with a number of
|
||||
tweaks that reflect intuition about text.
|
||||
"""
|
||||
if str1 is None and str2 is None:
|
||||
return 0.0
|
||||
if str1 is None or str2 is None:
|
||||
return 1.0
|
||||
|
||||
str1 = str1.lower()
|
||||
str2 = str2.lower()
|
||||
|
||||
# Don't penalize strings that move certain words to the end. For
|
||||
# example, "the something" should be considered equal to
|
||||
# "something, the".
|
||||
for word in SD_END_WORDS:
|
||||
if str1.endswith(f", {word}"):
|
||||
str1 = f"{word} {str1[: -len(word) - 2]}"
|
||||
if str2.endswith(f", {word}"):
|
||||
str2 = f"{word} {str2[: -len(word) - 2]}"
|
||||
|
||||
# Perform a couple of basic normalizing substitutions.
|
||||
for pat, repl in SD_REPLACE:
|
||||
str1 = re.sub(pat, repl, str1)
|
||||
str2 = re.sub(pat, repl, str2)
|
||||
|
||||
# Change the weight for certain string portions matched by a set
|
||||
# of regular expressions. We gradually change the strings and build
|
||||
# up penalties associated with parts of the string that were
|
||||
# deleted.
|
||||
base_dist = _string_dist_basic(str1, str2)
|
||||
penalty = 0.0
|
||||
for pat, weight in SD_PATTERNS:
|
||||
# Get strings that drop the pattern.
|
||||
case_str1 = re.sub(pat, "", str1)
|
||||
case_str2 = re.sub(pat, "", str2)
|
||||
|
||||
if case_str1 != str1 or case_str2 != str2:
|
||||
# If the pattern was present (i.e., it is deleted in the
|
||||
# the current case), recalculate the distances for the
|
||||
# modified strings.
|
||||
case_dist = _string_dist_basic(case_str1, case_str2)
|
||||
case_delta = max(0.0, base_dist - case_dist)
|
||||
if case_delta == 0.0:
|
||||
continue
|
||||
|
||||
# Shift our baseline strings down (to avoid rematching the
|
||||
# same part of the string) and add a scaled distance
|
||||
# amount to the penalties.
|
||||
str1 = case_str1
|
||||
str2 = case_str2
|
||||
base_dist = case_dist
|
||||
penalty += weight * case_delta
|
||||
|
||||
return base_dist + penalty
|
||||
|
||||
|
||||
@total_ordering
|
||||
class Distance:
|
||||
"""Keeps track of multiple distance penalties. Provides a single
|
||||
weighted distance for all penalties as well as a weighted distance
|
||||
for each individual penalty.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._penalties: dict[str, list[float]] = {}
|
||||
self.tracks: dict[TrackInfo, Distance] = {}
|
||||
|
||||
@cached_classproperty
|
||||
def _weights(cls) -> dict[str, float]:
|
||||
"""A dictionary from keys to floating-point weights."""
|
||||
weights_view = config["match"]["distance_weights"]
|
||||
weights = {}
|
||||
for key in weights_view.keys():
|
||||
weights[key] = weights_view[key].as_number()
|
||||
return weights
|
||||
|
||||
# Access the components and their aggregates.
|
||||
|
||||
@property
|
||||
def distance(self) -> float:
|
||||
"""Return a weighted and normalized distance across all
|
||||
penalties.
|
||||
"""
|
||||
dist_max = self.max_distance
|
||||
if dist_max:
|
||||
return self.raw_distance / self.max_distance
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def max_distance(self) -> float:
|
||||
"""Return the maximum distance penalty (normalization factor)."""
|
||||
dist_max = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_max += len(penalty) * self._weights[key]
|
||||
return dist_max
|
||||
|
||||
@property
|
||||
def raw_distance(self) -> float:
|
||||
"""Return the raw (denormalized) distance."""
|
||||
dist_raw = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_raw += sum(penalty) * self._weights[key]
|
||||
return dist_raw
|
||||
|
||||
def items(self) -> list[tuple[str, float]]:
|
||||
"""Return a list of (key, dist) pairs, with `dist` being the
|
||||
weighted distance, sorted from highest to lowest. Does not
|
||||
include penalties with a zero value.
|
||||
"""
|
||||
list_ = []
|
||||
for key in self._penalties:
|
||||
dist = self[key]
|
||||
if dist:
|
||||
list_.append((key, dist))
|
||||
# Convert distance into a negative float we can sort items in
|
||||
# ascending order (for keys, when the penalty is equal) and
|
||||
# still get the items with the biggest distance first.
|
||||
return sorted(
|
||||
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return id(self)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
return self.distance == other
|
||||
|
||||
# Behave like a float.
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.distance < other
|
||||
|
||||
def __float__(self) -> float:
|
||||
return self.distance
|
||||
|
||||
def __sub__(self, other) -> float:
|
||||
return self.distance - other
|
||||
|
||||
def __rsub__(self, other) -> float:
|
||||
return other - self.distance
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.distance:.2f}"
|
||||
|
||||
# Behave like a dict.
|
||||
|
||||
def __getitem__(self, key) -> float:
|
||||
"""Returns the weighted distance for a named penalty."""
|
||||
dist = sum(self._penalties[key]) * self._weights[key]
|
||||
dist_max = self.max_distance
|
||||
if dist_max:
|
||||
return dist / dist_max
|
||||
return 0.0
|
||||
|
||||
def __iter__(self) -> Iterator[tuple[str, float]]:
|
||||
return iter(self.items())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.items())
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
return [key for key, _ in self.items()]
|
||||
|
||||
def update(self, dist: Distance):
|
||||
"""Adds all the distance penalties from `dist`."""
|
||||
if not isinstance(dist, Distance):
|
||||
raise ValueError(
|
||||
f"`dist` must be a Distance object, not {type(dist)}"
|
||||
)
|
||||
for key, penalties in dist._penalties.items():
|
||||
self._penalties.setdefault(key, []).extend(penalties)
|
||||
|
||||
# Adding components.
|
||||
|
||||
def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool:
|
||||
"""Returns True if `value1` is equal to `value2`. `value1` may
|
||||
be a compiled regular expression, in which case it will be
|
||||
matched against `value2`.
|
||||
"""
|
||||
if isinstance(value1, re.Pattern):
|
||||
return bool(value1.match(value2))
|
||||
return value1 == value2
|
||||
|
||||
def add(self, key: str, dist: float):
|
||||
"""Adds a distance penalty. `key` must correspond with a
|
||||
configured weight setting. `dist` must be a float between 0.0
|
||||
and 1.0, and will be added to any existing distance penalties
|
||||
for the same key.
|
||||
"""
|
||||
if not 0.0 <= dist <= 1.0:
|
||||
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
|
||||
self._penalties.setdefault(key, []).append(dist)
|
||||
|
||||
def add_equality(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: list[Any] | tuple[Any, ...] | Any,
|
||||
):
|
||||
"""Adds a distance penalty of 1.0 if `value` doesn't match any
|
||||
of the values in `options`. If an option is a compiled regular
|
||||
expression, it will be considered equal if it matches against
|
||||
`value`.
|
||||
"""
|
||||
if not isinstance(options, (list, tuple)):
|
||||
options = [options]
|
||||
for opt in options:
|
||||
if self._eq(opt, value):
|
||||
dist = 0.0
|
||||
break
|
||||
else:
|
||||
dist = 1.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_expr(self, key: str, expr: bool):
|
||||
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
|
||||
or 0.0.
|
||||
"""
|
||||
if expr:
|
||||
self.add(key, 1.0)
|
||||
else:
|
||||
self.add(key, 0.0)
|
||||
|
||||
def add_number(self, key: str, number1: int, number2: int):
|
||||
"""Adds a distance penalty of 1.0 for each number of difference
|
||||
between `number1` and `number2`, or 0.0 when there is no
|
||||
difference. Use this when there is no upper limit on the
|
||||
difference between the two numbers.
|
||||
"""
|
||||
diff = abs(number1 - number2)
|
||||
if diff:
|
||||
for i in range(diff):
|
||||
self.add(key, 1.0)
|
||||
else:
|
||||
self.add(key, 0.0)
|
||||
|
||||
def add_priority(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: list[Any] | tuple[Any, ...] | Any,
|
||||
):
|
||||
"""Adds a distance penalty that corresponds to the position at
|
||||
which `value` appears in `options`. A distance penalty of 0.0
|
||||
for the first option, or 1.0 if there is no matching option. If
|
||||
an option is a compiled regular expression, it will be
|
||||
considered equal if it matches against `value`.
|
||||
"""
|
||||
if not isinstance(options, (list, tuple)):
|
||||
options = [options]
|
||||
unit = 1.0 / (len(options) or 1)
|
||||
for i, opt in enumerate(options):
|
||||
if self._eq(opt, value):
|
||||
dist = i * unit
|
||||
break
|
||||
else:
|
||||
dist = 1.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_ratio(
|
||||
self,
|
||||
key: str,
|
||||
number1: int | float,
|
||||
number2: int | float,
|
||||
):
|
||||
"""Adds a distance penalty for `number1` as a ratio of `number2`.
|
||||
`number1` is bound at 0 and `number2`.
|
||||
"""
|
||||
number = float(max(min(number1, number2), 0))
|
||||
if number2:
|
||||
dist = number / number2
|
||||
else:
|
||||
dist = 0.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_string(self, key: str, str1: str | None, str2: str | None):
|
||||
"""Adds a distance penalty based on the edit distance between
|
||||
`str1` and `str2`.
|
||||
"""
|
||||
dist = string_dist(str1, str2)
|
||||
self.add(key, dist)
|
||||
|
||||
def add_data_source(self, before: str | None, after: str | None) -> None:
|
||||
if before != after and (
|
||||
before or len(metadata_plugins.find_metadata_source_plugins()) > 1
|
||||
):
|
||||
self.add("data_source", metadata_plugins.get_penalty(after))
|
||||
|
||||
|
||||
@cache
|
||||
def get_track_length_grace() -> float:
|
||||
"""Get cached grace period for track length matching."""
|
||||
return config["match"]["track_length_grace"].as_number()
|
||||
|
||||
|
||||
@cache
|
||||
def get_track_length_max() -> float:
|
||||
"""Get cached maximum track length for track length matching."""
|
||||
return config["match"]["track_length_max"].as_number()
|
||||
|
||||
|
||||
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
|
||||
"""Returns True if the item and track info index is different. Tolerates
|
||||
per disc and per release numbering.
|
||||
"""
|
||||
return item.track not in (track_info.medium_index, track_info.index)
|
||||
|
||||
|
||||
def track_distance(
|
||||
item: Item,
|
||||
track_info: TrackInfo,
|
||||
incl_artist: bool = False,
|
||||
) -> Distance:
|
||||
"""Determines the significance of a track metadata change. Returns a
|
||||
Distance object. `incl_artist` indicates that a distance component should
|
||||
be included for the track artist (i.e., for various-artist releases).
|
||||
|
||||
``track_length_grace`` and ``track_length_max`` configuration options are
|
||||
cached because this function is called many times during the matching
|
||||
process and their access comes with a performance overhead.
|
||||
"""
|
||||
dist = Distance()
|
||||
|
||||
# Length.
|
||||
if info_length := track_info.length:
|
||||
diff = abs(item.length - info_length) - get_track_length_grace()
|
||||
dist.add_ratio("track_length", diff, get_track_length_max())
|
||||
|
||||
# Title.
|
||||
dist.add_string("track_title", item.title, track_info.title)
|
||||
|
||||
# Artist. Only check if there is actually an artist in the track data.
|
||||
if (
|
||||
incl_artist
|
||||
and track_info.artist
|
||||
and item.artist.lower() not in VA_ARTISTS
|
||||
):
|
||||
dist.add_string("track_artist", item.artist, track_info.artist)
|
||||
|
||||
# Track index.
|
||||
if track_info.index and item.track:
|
||||
dist.add_expr("track_index", track_index_changed(item, track_info))
|
||||
|
||||
# Track ID.
|
||||
if item.mb_trackid:
|
||||
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
|
||||
|
||||
# Penalize mismatching disc numbers.
|
||||
if track_info.medium and item.disc:
|
||||
dist.add_expr("medium", item.disc != track_info.medium)
|
||||
|
||||
dist.add_data_source(item.get("data_source"), track_info.data_source)
|
||||
|
||||
return dist
|
||||
|
||||
|
||||
def distance(
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
item_info_pairs: list[tuple[Item, TrackInfo]],
|
||||
) -> Distance:
|
||||
"""Determines how "significant" an album metadata change would be.
|
||||
Returns a Distance object. `album_info` is an AlbumInfo object
|
||||
reflecting the album to be compared. `items` is a sequence of all
|
||||
Item objects that will be matched (order is not important).
|
||||
`mapping` is a dictionary mapping Items to TrackInfo objects; the
|
||||
keys are a subset of `items` and the values are a subset of
|
||||
`album_info.tracks`.
|
||||
"""
|
||||
likelies, _ = get_most_common_tags(items)
|
||||
|
||||
dist = Distance()
|
||||
|
||||
# Artist, if not various.
|
||||
if not album_info.va:
|
||||
dist.add_string("artist", likelies["artist"], album_info.artist)
|
||||
|
||||
# Album.
|
||||
dist.add_string("album", likelies["album"], album_info.album)
|
||||
|
||||
preferred_config = config["match"]["preferred"]
|
||||
# Current or preferred media.
|
||||
if album_info.media:
|
||||
# Preferred media options.
|
||||
media_patterns: Sequence[str] = preferred_config["media"].as_str_seq()
|
||||
options = [
|
||||
re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns
|
||||
]
|
||||
if options:
|
||||
dist.add_priority("media", album_info.media, options)
|
||||
# Current media.
|
||||
elif likelies["media"]:
|
||||
dist.add_equality("media", album_info.media, likelies["media"])
|
||||
|
||||
# Mediums.
|
||||
if likelies["disctotal"] and album_info.mediums:
|
||||
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
|
||||
|
||||
# Prefer earliest release.
|
||||
if album_info.year and preferred_config["original_year"]:
|
||||
# Assume 1889 (earliest first gramophone discs) if we don't know the
|
||||
# original year.
|
||||
original = album_info.original_year or 1889
|
||||
diff = abs(album_info.year - original)
|
||||
diff_max = abs(datetime.date.today().year - original)
|
||||
dist.add_ratio("year", diff, diff_max)
|
||||
# Year.
|
||||
elif likelies["year"] and album_info.year:
|
||||
if likelies["year"] in (album_info.year, album_info.original_year):
|
||||
# No penalty for matching release or original year.
|
||||
dist.add("year", 0.0)
|
||||
elif album_info.original_year:
|
||||
# Prefer matchest closest to the release year.
|
||||
diff = abs(likelies["year"] - album_info.year)
|
||||
diff_max = abs(
|
||||
datetime.date.today().year - album_info.original_year
|
||||
)
|
||||
dist.add_ratio("year", diff, diff_max)
|
||||
else:
|
||||
# Full penalty when there is no original year.
|
||||
dist.add("year", 1.0)
|
||||
|
||||
# Preferred countries.
|
||||
country_patterns: Sequence[str] = preferred_config["countries"].as_str_seq()
|
||||
options = [re.compile(pat, re.I) for pat in country_patterns]
|
||||
if album_info.country and options:
|
||||
dist.add_priority("country", album_info.country, options)
|
||||
# Country.
|
||||
elif likelies["country"] and album_info.country:
|
||||
dist.add_string("country", likelies["country"], album_info.country)
|
||||
|
||||
# Label.
|
||||
if likelies["label"] and album_info.label:
|
||||
dist.add_string("label", likelies["label"], album_info.label)
|
||||
|
||||
# Catalog number.
|
||||
if likelies["catalognum"] and album_info.catalognum:
|
||||
dist.add_string(
|
||||
"catalognum", likelies["catalognum"], album_info.catalognum
|
||||
)
|
||||
|
||||
# Disambiguation.
|
||||
if likelies["albumdisambig"] and album_info.albumdisambig:
|
||||
dist.add_string(
|
||||
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
|
||||
)
|
||||
|
||||
# Album ID.
|
||||
if likelies["mb_albumid"]:
|
||||
dist.add_equality(
|
||||
"album_id", likelies["mb_albumid"], album_info.album_id
|
||||
)
|
||||
|
||||
# Tracks.
|
||||
dist.tracks = {}
|
||||
for item, track in item_info_pairs:
|
||||
dist.tracks[track] = track_distance(item, track, album_info.va)
|
||||
dist.add("tracks", dist.tracks[track].distance)
|
||||
|
||||
# Missing tracks.
|
||||
for _ in range(len(album_info.tracks) - len(item_info_pairs)):
|
||||
dist.add("missing_tracks", 1.0)
|
||||
|
||||
# Unmatched tracks.
|
||||
for _ in range(len(items) - len(item_info_pairs)):
|
||||
dist.add("unmatched_tracks", 1.0)
|
||||
|
||||
dist.add_data_source(likelies["data_source"], album_info.data_source)
|
||||
|
||||
return dist
|
||||
|
|
@ -16,675 +16,246 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import total_ordering
|
||||
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar, cast
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from jellyfish import levenshtein_distance
|
||||
from unidecode import unidecode
|
||||
from typing_extensions import Self
|
||||
|
||||
from beets import config, logging, plugins
|
||||
from beets.autotag import mb
|
||||
from beets.util import as_string, cached_classproperty
|
||||
from beets.util import cached_classproperty
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
from beets.library import Item
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
from .distance import Distance
|
||||
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
# Classes used to represent candidate options.
|
||||
class AttrDict(dict[str, V]):
|
||||
"""A dictionary that supports attribute ("dot") access, so `d.field`
|
||||
is equivalent to `d['field']`.
|
||||
"""
|
||||
"""Mapping enabling attribute-style access to stored metadata values."""
|
||||
|
||||
def copy(self) -> Self:
|
||||
return deepcopy(self)
|
||||
|
||||
def __getattr__(self, attr: str) -> V:
|
||||
if attr in self:
|
||||
return self[attr]
|
||||
else:
|
||||
raise AttributeError
|
||||
|
||||
def __setattr__(self, key: str, value: V):
|
||||
raise AttributeError(
|
||||
f"'{self.__class__.__name__}' object has no attribute '{attr}'"
|
||||
)
|
||||
|
||||
def __setattr__(self, key: str, value: V) -> None:
|
||||
self.__setitem__(key, value)
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int: # type: ignore[override]
|
||||
return id(self)
|
||||
|
||||
|
||||
class AlbumInfo(AttrDict):
|
||||
"""Describes a canonical release that may be used to match a release
|
||||
in the library. Consists of these data members:
|
||||
class Info(AttrDict[Any]):
|
||||
"""Container for metadata about a musical entity."""
|
||||
|
||||
- ``album``: the release title
|
||||
- ``album_id``: MusicBrainz ID; UUID fragment only
|
||||
- ``artist``: name of the release's primary artist
|
||||
- ``artist_id``
|
||||
- ``tracks``: list of TrackInfo objects making up the release
|
||||
@cached_property
|
||||
def name(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
``mediums`` along with the fields up through ``tracks`` are required.
|
||||
The others are optional and may be None.
|
||||
def __init__(
|
||||
self,
|
||||
album: str | None = None,
|
||||
artist_credit: str | None = None,
|
||||
artist_id: str | None = None,
|
||||
artist: str | None = None,
|
||||
artists_credit: list[str] | None = None,
|
||||
artists_ids: list[str] | None = None,
|
||||
artists: list[str] | None = None,
|
||||
artist_sort: str | None = None,
|
||||
artists_sort: list[str] | None = None,
|
||||
data_source: str | None = None,
|
||||
data_url: str | None = None,
|
||||
genre: str | None = None,
|
||||
media: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.album = album
|
||||
self.artist = artist
|
||||
self.artist_credit = artist_credit
|
||||
self.artist_id = artist_id
|
||||
self.artists = artists or []
|
||||
self.artists_credit = artists_credit or []
|
||||
self.artists_ids = artists_ids or []
|
||||
self.artist_sort = artist_sort
|
||||
self.artists_sort = artists_sort or []
|
||||
self.data_source = data_source
|
||||
self.data_url = data_url
|
||||
self.genre = genre
|
||||
self.media = media
|
||||
self.update(kwargs)
|
||||
|
||||
|
||||
class AlbumInfo(Info):
|
||||
"""Metadata snapshot representing a single album candidate.
|
||||
|
||||
Aggregates track entries and album-wide context gathered from an external
|
||||
provider. Used during matching to evaluate similarity against a group of
|
||||
user items, and later to drive tagging decisions once selected.
|
||||
"""
|
||||
|
||||
# TYPING: are all of these correct? I've assumed optional strings
|
||||
@cached_property
|
||||
def name(self) -> str:
|
||||
return self.album or ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tracks: list[TrackInfo],
|
||||
album: str | None = None,
|
||||
*,
|
||||
album_id: str | None = None,
|
||||
artist: str | None = None,
|
||||
artist_id: str | None = None,
|
||||
artists: list[str] | None = None,
|
||||
artists_ids: list[str] | None = None,
|
||||
asin: str | None = None,
|
||||
albumdisambig: str | None = None,
|
||||
albumstatus: str | None = None,
|
||||
albumtype: str | None = None,
|
||||
albumtypes: list[str] | None = None,
|
||||
asin: str | None = None,
|
||||
barcode: str | None = None,
|
||||
catalognum: str | None = None,
|
||||
country: str | None = None,
|
||||
day: int | None = None,
|
||||
discogs_albumid: str | None = None,
|
||||
discogs_artistid: str | None = None,
|
||||
discogs_labelid: str | None = None,
|
||||
label: str | None = None,
|
||||
language: str | None = None,
|
||||
mediums: int | None = None,
|
||||
month: int | None = None,
|
||||
original_day: int | None = None,
|
||||
original_month: int | None = None,
|
||||
original_year: int | None = None,
|
||||
release_group_title: str | None = None,
|
||||
releasegroup_id: str | None = None,
|
||||
releasegroupdisambig: str | None = None,
|
||||
script: str | None = None,
|
||||
style: str | None = None,
|
||||
va: bool = False,
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
day: int | None = None,
|
||||
label: str | None = None,
|
||||
barcode: str | None = None,
|
||||
mediums: int | None = None,
|
||||
artist_sort: str | None = None,
|
||||
artists_sort: list[str] | None = None,
|
||||
releasegroup_id: str | None = None,
|
||||
release_group_title: str | None = None,
|
||||
catalognum: str | None = None,
|
||||
script: str | None = None,
|
||||
language: str | None = None,
|
||||
country: str | None = None,
|
||||
style: str | None = None,
|
||||
genre: str | None = None,
|
||||
albumstatus: str | None = None,
|
||||
media: str | None = None,
|
||||
albumdisambig: str | None = None,
|
||||
releasegroupdisambig: str | None = None,
|
||||
artist_credit: str | None = None,
|
||||
artists_credit: list[str] | None = None,
|
||||
original_year: int | None = None,
|
||||
original_month: int | None = None,
|
||||
original_day: int | None = None,
|
||||
data_source: str | None = None,
|
||||
data_url: str | None = None,
|
||||
discogs_albumid: str | None = None,
|
||||
discogs_labelid: str | None = None,
|
||||
discogs_artistid: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.album = album
|
||||
self.album_id = album_id
|
||||
self.artist = artist
|
||||
self.artist_id = artist_id
|
||||
self.artists = artists or []
|
||||
self.artists_ids = artists_ids or []
|
||||
) -> None:
|
||||
self.tracks = tracks
|
||||
self.asin = asin
|
||||
self.album_id = album_id
|
||||
self.albumdisambig = albumdisambig
|
||||
self.albumstatus = albumstatus
|
||||
self.albumtype = albumtype
|
||||
self.albumtypes = albumtypes or []
|
||||
self.asin = asin
|
||||
self.barcode = barcode
|
||||
self.catalognum = catalognum
|
||||
self.country = country
|
||||
self.day = day
|
||||
self.discogs_albumid = discogs_albumid
|
||||
self.discogs_artistid = discogs_artistid
|
||||
self.discogs_labelid = discogs_labelid
|
||||
self.label = label
|
||||
self.language = language
|
||||
self.mediums = mediums
|
||||
self.month = month
|
||||
self.original_day = original_day
|
||||
self.original_month = original_month
|
||||
self.original_year = original_year
|
||||
self.release_group_title = release_group_title
|
||||
self.releasegroup_id = releasegroup_id
|
||||
self.releasegroupdisambig = releasegroupdisambig
|
||||
self.script = script
|
||||
self.style = style
|
||||
self.va = va
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
self.label = label
|
||||
self.barcode = barcode
|
||||
self.mediums = mediums
|
||||
self.artist_sort = artist_sort
|
||||
self.artists_sort = artists_sort or []
|
||||
self.releasegroup_id = releasegroup_id
|
||||
self.release_group_title = release_group_title
|
||||
self.catalognum = catalognum
|
||||
self.script = script
|
||||
self.language = language
|
||||
self.country = country
|
||||
self.style = style
|
||||
self.genre = genre
|
||||
self.albumstatus = albumstatus
|
||||
self.media = media
|
||||
self.albumdisambig = albumdisambig
|
||||
self.releasegroupdisambig = releasegroupdisambig
|
||||
self.artist_credit = artist_credit
|
||||
self.artists_credit = artists_credit or []
|
||||
self.original_year = original_year
|
||||
self.original_month = original_month
|
||||
self.original_day = original_day
|
||||
self.data_source = data_source
|
||||
self.data_url = data_url
|
||||
self.discogs_albumid = discogs_albumid
|
||||
self.discogs_labelid = discogs_labelid
|
||||
self.discogs_artistid = discogs_artistid
|
||||
self.update(kwargs)
|
||||
|
||||
def copy(self) -> AlbumInfo:
|
||||
dupe = AlbumInfo([])
|
||||
dupe.update(self)
|
||||
dupe.tracks = [track.copy() for track in self.tracks]
|
||||
return dupe
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class TrackInfo(AttrDict):
|
||||
"""Describes a canonical track present on a release. Appears as part
|
||||
of an AlbumInfo's ``tracks`` list. Consists of these data members:
|
||||
class TrackInfo(Info):
|
||||
"""Metadata snapshot for a single track candidate.
|
||||
|
||||
- ``title``: name of the track
|
||||
- ``track_id``: MusicBrainz ID; UUID fragment only
|
||||
|
||||
Only ``title`` and ``track_id`` are required. The rest of the fields
|
||||
may be None. The indices ``index``, ``medium``, and ``medium_index``
|
||||
are all 1-based.
|
||||
Captures identifying details and creative credits used to compare against
|
||||
a user's item. Instances often originate within an AlbumInfo but may also
|
||||
stand alone for singleton matching.
|
||||
"""
|
||||
|
||||
# TYPING: are all of these correct? I've assumed optional strings
|
||||
@cached_property
|
||||
def name(self) -> str:
|
||||
return self.title or ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str | None = None,
|
||||
track_id: str | None = None,
|
||||
release_track_id: str | None = None,
|
||||
artist: str | None = None,
|
||||
artist_id: str | None = None,
|
||||
artists: list[str] | None = None,
|
||||
artists_ids: list[str] | None = None,
|
||||
length: float | None = None,
|
||||
*,
|
||||
arranger: str | None = None,
|
||||
bpm: str | None = None,
|
||||
composer: str | None = None,
|
||||
composer_sort: str | None = None,
|
||||
disctitle: str | None = None,
|
||||
index: int | None = None,
|
||||
initial_key: str | None = None,
|
||||
length: float | None = None,
|
||||
lyricist: str | None = None,
|
||||
mb_workid: str | None = None,
|
||||
medium: int | None = None,
|
||||
medium_index: int | None = None,
|
||||
medium_total: int | None = None,
|
||||
artist_sort: str | None = None,
|
||||
artists_sort: list[str] | None = None,
|
||||
disctitle: str | None = None,
|
||||
artist_credit: str | None = None,
|
||||
artists_credit: list[str] | None = None,
|
||||
data_source: str | None = None,
|
||||
data_url: str | None = None,
|
||||
media: str | None = None,
|
||||
lyricist: str | None = None,
|
||||
composer: str | None = None,
|
||||
composer_sort: str | None = None,
|
||||
arranger: str | None = None,
|
||||
release_track_id: str | None = None,
|
||||
title: str | None = None,
|
||||
track_alt: str | None = None,
|
||||
track_id: str | None = None,
|
||||
work: str | None = None,
|
||||
mb_workid: str | None = None,
|
||||
work_disambig: str | None = None,
|
||||
bpm: str | None = None,
|
||||
initial_key: str | None = None,
|
||||
genre: str | None = None,
|
||||
album: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.title = title
|
||||
self.track_id = track_id
|
||||
self.release_track_id = release_track_id
|
||||
self.artist = artist
|
||||
self.artist_id = artist_id
|
||||
self.artists = artists or []
|
||||
self.artists_ids = artists_ids or []
|
||||
self.length = length
|
||||
) -> None:
|
||||
self.arranger = arranger
|
||||
self.bpm = bpm
|
||||
self.composer = composer
|
||||
self.composer_sort = composer_sort
|
||||
self.disctitle = disctitle
|
||||
self.index = index
|
||||
self.media = media
|
||||
self.initial_key = initial_key
|
||||
self.length = length
|
||||
self.lyricist = lyricist
|
||||
self.mb_workid = mb_workid
|
||||
self.medium = medium
|
||||
self.medium_index = medium_index
|
||||
self.medium_total = medium_total
|
||||
self.artist_sort = artist_sort
|
||||
self.artists_sort = artists_sort or []
|
||||
self.disctitle = disctitle
|
||||
self.artist_credit = artist_credit
|
||||
self.artists_credit = artists_credit or []
|
||||
self.data_source = data_source
|
||||
self.data_url = data_url
|
||||
self.lyricist = lyricist
|
||||
self.composer = composer
|
||||
self.composer_sort = composer_sort
|
||||
self.arranger = arranger
|
||||
self.release_track_id = release_track_id
|
||||
self.title = title
|
||||
self.track_alt = track_alt
|
||||
self.track_id = track_id
|
||||
self.work = work
|
||||
self.mb_workid = mb_workid
|
||||
self.work_disambig = work_disambig
|
||||
self.bpm = bpm
|
||||
self.initial_key = initial_key
|
||||
self.genre = genre
|
||||
self.album = album
|
||||
self.update(kwargs)
|
||||
|
||||
def copy(self) -> TrackInfo:
|
||||
dupe = TrackInfo()
|
||||
dupe.update(self)
|
||||
return dupe
|
||||
|
||||
|
||||
# Candidate distance scoring.
|
||||
|
||||
# Parameters for string distance function.
|
||||
# Words that can be moved to the end of a string using a comma.
|
||||
SD_END_WORDS = ["the", "a", "an"]
|
||||
# Reduced weights for certain portions of the string.
|
||||
SD_PATTERNS = [
|
||||
(r"^the ", 0.1),
|
||||
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
|
||||
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
|
||||
(r"\(.*?\)", 0.3),
|
||||
(r"\[.*?\]", 0.3),
|
||||
(r"(, )?(pt\.|part) .+", 0.2),
|
||||
]
|
||||
# Replacements to use before testing distance.
|
||||
SD_REPLACE = [
|
||||
(r"&", "and"),
|
||||
]
|
||||
|
||||
|
||||
def _string_dist_basic(str1: str, str2: str) -> float:
|
||||
"""Basic edit distance between two strings, ignoring
|
||||
non-alphanumeric characters and case. Comparisons are based on a
|
||||
transliteration/lowering to ASCII characters. Normalized by string
|
||||
length.
|
||||
"""
|
||||
assert isinstance(str1, str)
|
||||
assert isinstance(str2, str)
|
||||
str1 = as_string(unidecode(str1))
|
||||
str2 = as_string(unidecode(str2))
|
||||
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
|
||||
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
|
||||
if not str1 and not str2:
|
||||
return 0.0
|
||||
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
|
||||
|
||||
|
||||
def string_dist(str1: str | None, str2: str | None) -> float:
|
||||
"""Gives an "intuitive" edit distance between two strings. This is
|
||||
an edit distance, normalized by the string length, with a number of
|
||||
tweaks that reflect intuition about text.
|
||||
"""
|
||||
if str1 is None and str2 is None:
|
||||
return 0.0
|
||||
if str1 is None or str2 is None:
|
||||
return 1.0
|
||||
|
||||
str1 = str1.lower()
|
||||
str2 = str2.lower()
|
||||
|
||||
# Don't penalize strings that move certain words to the end. For
|
||||
# example, "the something" should be considered equal to
|
||||
# "something, the".
|
||||
for word in SD_END_WORDS:
|
||||
if str1.endswith(", %s" % word):
|
||||
str1 = "{} {}".format(word, str1[: -len(word) - 2])
|
||||
if str2.endswith(", %s" % word):
|
||||
str2 = "{} {}".format(word, str2[: -len(word) - 2])
|
||||
|
||||
# Perform a couple of basic normalizing substitutions.
|
||||
for pat, repl in SD_REPLACE:
|
||||
str1 = re.sub(pat, repl, str1)
|
||||
str2 = re.sub(pat, repl, str2)
|
||||
|
||||
# Change the weight for certain string portions matched by a set
|
||||
# of regular expressions. We gradually change the strings and build
|
||||
# up penalties associated with parts of the string that were
|
||||
# deleted.
|
||||
base_dist = _string_dist_basic(str1, str2)
|
||||
penalty = 0.0
|
||||
for pat, weight in SD_PATTERNS:
|
||||
# Get strings that drop the pattern.
|
||||
case_str1 = re.sub(pat, "", str1)
|
||||
case_str2 = re.sub(pat, "", str2)
|
||||
|
||||
if case_str1 != str1 or case_str2 != str2:
|
||||
# If the pattern was present (i.e., it is deleted in the
|
||||
# the current case), recalculate the distances for the
|
||||
# modified strings.
|
||||
case_dist = _string_dist_basic(case_str1, case_str2)
|
||||
case_delta = max(0.0, base_dist - case_dist)
|
||||
if case_delta == 0.0:
|
||||
continue
|
||||
|
||||
# Shift our baseline strings down (to avoid rematching the
|
||||
# same part of the string) and add a scaled distance
|
||||
# amount to the penalties.
|
||||
str1 = case_str1
|
||||
str2 = case_str2
|
||||
base_dist = case_dist
|
||||
penalty += weight * case_delta
|
||||
|
||||
return base_dist + penalty
|
||||
|
||||
|
||||
@total_ordering
|
||||
class Distance:
|
||||
"""Keeps track of multiple distance penalties. Provides a single
|
||||
weighted distance for all penalties as well as a weighted distance
|
||||
for each individual penalty.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._penalties = {}
|
||||
self.tracks: dict[TrackInfo, Distance] = {}
|
||||
|
||||
@cached_classproperty
|
||||
def _weights(cls) -> dict[str, float]:
|
||||
"""A dictionary from keys to floating-point weights."""
|
||||
weights_view = config["match"]["distance_weights"]
|
||||
weights = {}
|
||||
for key in weights_view.keys():
|
||||
weights[key] = weights_view[key].as_number()
|
||||
return weights
|
||||
|
||||
# Access the components and their aggregates.
|
||||
|
||||
@property
|
||||
def distance(self) -> float:
|
||||
"""Return a weighted and normalized distance across all
|
||||
penalties.
|
||||
"""
|
||||
dist_max = self.max_distance
|
||||
if dist_max:
|
||||
return self.raw_distance / self.max_distance
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def max_distance(self) -> float:
|
||||
"""Return the maximum distance penalty (normalization factor)."""
|
||||
dist_max = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_max += len(penalty) * self._weights[key]
|
||||
return dist_max
|
||||
|
||||
@property
|
||||
def raw_distance(self) -> float:
|
||||
"""Return the raw (denormalized) distance."""
|
||||
dist_raw = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_raw += sum(penalty) * self._weights[key]
|
||||
return dist_raw
|
||||
|
||||
def items(self) -> list[tuple[str, float]]:
|
||||
"""Return a list of (key, dist) pairs, with `dist` being the
|
||||
weighted distance, sorted from highest to lowest. Does not
|
||||
include penalties with a zero value.
|
||||
"""
|
||||
list_ = []
|
||||
for key in self._penalties:
|
||||
dist = self[key]
|
||||
if dist:
|
||||
list_.append((key, dist))
|
||||
# Convert distance into a negative float we can sort items in
|
||||
# ascending order (for keys, when the penalty is equal) and
|
||||
# still get the items with the biggest distance first.
|
||||
return sorted(
|
||||
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return id(self)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
return self.distance == other
|
||||
|
||||
# Behave like a float.
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.distance < other
|
||||
|
||||
def __float__(self) -> float:
|
||||
return self.distance
|
||||
|
||||
def __sub__(self, other) -> float:
|
||||
return self.distance - other
|
||||
|
||||
def __rsub__(self, other) -> float:
|
||||
return other - self.distance
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.distance:.2f}"
|
||||
|
||||
# Behave like a dict.
|
||||
|
||||
def __getitem__(self, key) -> float:
|
||||
"""Returns the weighted distance for a named penalty."""
|
||||
dist = sum(self._penalties[key]) * self._weights[key]
|
||||
dist_max = self.max_distance
|
||||
if dist_max:
|
||||
return dist / dist_max
|
||||
return 0.0
|
||||
|
||||
def __iter__(self) -> Iterator[tuple[str, float]]:
|
||||
return iter(self.items())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.items())
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
return [key for key, _ in self.items()]
|
||||
|
||||
def update(self, dist: Distance):
|
||||
"""Adds all the distance penalties from `dist`."""
|
||||
if not isinstance(dist, Distance):
|
||||
raise ValueError(
|
||||
"`dist` must be a Distance object, not {}".format(type(dist))
|
||||
)
|
||||
for key, penalties in dist._penalties.items():
|
||||
self._penalties.setdefault(key, []).extend(penalties)
|
||||
|
||||
# Adding components.
|
||||
|
||||
def _eq(self, value1: re.Pattern[str] | Any, value2: Any) -> bool:
|
||||
"""Returns True if `value1` is equal to `value2`. `value1` may
|
||||
be a compiled regular expression, in which case it will be
|
||||
matched against `value2`.
|
||||
"""
|
||||
if isinstance(value1, re.Pattern):
|
||||
value2 = cast(str, value2)
|
||||
return bool(value1.match(value2))
|
||||
return value1 == value2
|
||||
|
||||
def add(self, key: str, dist: float):
|
||||
"""Adds a distance penalty. `key` must correspond with a
|
||||
configured weight setting. `dist` must be a float between 0.0
|
||||
and 1.0, and will be added to any existing distance penalties
|
||||
for the same key.
|
||||
"""
|
||||
if not 0.0 <= dist <= 1.0:
|
||||
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
|
||||
self._penalties.setdefault(key, []).append(dist)
|
||||
|
||||
def add_equality(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: list[Any] | tuple[Any, ...] | Any,
|
||||
):
|
||||
"""Adds a distance penalty of 1.0 if `value` doesn't match any
|
||||
of the values in `options`. If an option is a compiled regular
|
||||
expression, it will be considered equal if it matches against
|
||||
`value`.
|
||||
"""
|
||||
if not isinstance(options, (list, tuple)):
|
||||
options = [options]
|
||||
for opt in options:
|
||||
if self._eq(opt, value):
|
||||
dist = 0.0
|
||||
break
|
||||
else:
|
||||
dist = 1.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_expr(self, key: str, expr: bool):
|
||||
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
|
||||
or 0.0.
|
||||
"""
|
||||
if expr:
|
||||
self.add(key, 1.0)
|
||||
else:
|
||||
self.add(key, 0.0)
|
||||
|
||||
def add_number(self, key: str, number1: int, number2: int):
|
||||
"""Adds a distance penalty of 1.0 for each number of difference
|
||||
between `number1` and `number2`, or 0.0 when there is no
|
||||
difference. Use this when there is no upper limit on the
|
||||
difference between the two numbers.
|
||||
"""
|
||||
diff = abs(number1 - number2)
|
||||
if diff:
|
||||
for i in range(diff):
|
||||
self.add(key, 1.0)
|
||||
else:
|
||||
self.add(key, 0.0)
|
||||
|
||||
def add_priority(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: list[Any] | tuple[Any, ...] | Any,
|
||||
):
|
||||
"""Adds a distance penalty that corresponds to the position at
|
||||
which `value` appears in `options`. A distance penalty of 0.0
|
||||
for the first option, or 1.0 if there is no matching option. If
|
||||
an option is a compiled regular expression, it will be
|
||||
considered equal if it matches against `value`.
|
||||
"""
|
||||
if not isinstance(options, (list, tuple)):
|
||||
options = [options]
|
||||
unit = 1.0 / (len(options) or 1)
|
||||
for i, opt in enumerate(options):
|
||||
if self._eq(opt, value):
|
||||
dist = i * unit
|
||||
break
|
||||
else:
|
||||
dist = 1.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_ratio(
|
||||
self,
|
||||
key: str,
|
||||
number1: int | float,
|
||||
number2: int | float,
|
||||
):
|
||||
"""Adds a distance penalty for `number1` as a ratio of `number2`.
|
||||
`number1` is bound at 0 and `number2`.
|
||||
"""
|
||||
number = float(max(min(number1, number2), 0))
|
||||
if number2:
|
||||
dist = number / number2
|
||||
else:
|
||||
dist = 0.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_string(self, key: str, str1: str | None, str2: str | None):
|
||||
"""Adds a distance penalty based on the edit distance between
|
||||
`str1` and `str2`.
|
||||
"""
|
||||
dist = string_dist(str1, str2)
|
||||
self.add(key, dist)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
# Structures that compose all the information for a candidate match.
|
||||
|
||||
|
||||
class AlbumMatch(NamedTuple):
|
||||
@dataclass
|
||||
class Match:
|
||||
distance: Distance
|
||||
info: Info
|
||||
|
||||
@cached_classproperty
|
||||
def type(cls) -> str:
|
||||
return cls.__name__.removesuffix("Match") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumMatch(Match):
|
||||
info: AlbumInfo
|
||||
mapping: dict[Item, TrackInfo]
|
||||
extra_items: list[Item]
|
||||
extra_tracks: list[TrackInfo]
|
||||
|
||||
@property
|
||||
def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
|
||||
return list(self.mapping.items())
|
||||
|
||||
class TrackMatch(NamedTuple):
|
||||
distance: Distance
|
||||
@property
|
||||
def items(self) -> list[Item]:
|
||||
return [i for i, _ in self.item_info_pairs]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackMatch(Match):
|
||||
info: TrackInfo
|
||||
|
||||
|
||||
# Aggregation of sources.
|
||||
|
||||
|
||||
def album_for_mbid(release_id: str) -> AlbumInfo | None:
|
||||
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
|
||||
if the ID is not found.
|
||||
"""
|
||||
try:
|
||||
if album := mb.album_for_id(release_id):
|
||||
plugins.send("albuminfo_received", info=album)
|
||||
return album
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
return None
|
||||
|
||||
|
||||
def track_for_mbid(recording_id: str) -> TrackInfo | None:
|
||||
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
|
||||
if the ID is not found.
|
||||
"""
|
||||
try:
|
||||
if track := mb.track_for_id(recording_id):
|
||||
plugins.send("trackinfo_received", info=track)
|
||||
return track
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
return None
|
||||
|
||||
|
||||
def album_for_id(_id: str) -> AlbumInfo | None:
|
||||
"""Get AlbumInfo object for the given ID string."""
|
||||
return album_for_mbid(_id) or plugins.album_for_id(_id)
|
||||
|
||||
|
||||
def track_for_id(_id: str) -> TrackInfo | None:
|
||||
"""Get TrackInfo object for the given ID string."""
|
||||
return track_for_mbid(_id) or plugins.track_for_id(_id)
|
||||
|
||||
|
||||
def invoke_mb(call_func: Callable, *args):
|
||||
try:
|
||||
return call_func(*args)
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
return ()
|
||||
|
||||
|
||||
@plugins.notify_info_yielded("albuminfo_received")
|
||||
def album_candidates(
|
||||
items: list[Item],
|
||||
artist: str,
|
||||
album: str,
|
||||
va_likely: bool,
|
||||
extra_tags: dict,
|
||||
) -> Iterable[tuple]:
|
||||
"""Search for album matches. ``items`` is a list of Item objects
|
||||
that make up the album. ``artist`` and ``album`` are the respective
|
||||
names (strings), which may be derived from the item list or may be
|
||||
entered by the user. ``va_likely`` is a boolean indicating whether
|
||||
the album is likely to be a "various artists" release. ``extra_tags``
|
||||
is an optional dictionary of additional tags used to further
|
||||
constrain the search.
|
||||
"""
|
||||
|
||||
if config["musicbrainz"]["enabled"]:
|
||||
# Base candidates if we have album and artist to match.
|
||||
if artist and album:
|
||||
yield from invoke_mb(
|
||||
mb.match_album, artist, album, len(items), extra_tags
|
||||
)
|
||||
|
||||
# Also add VA matches from MusicBrainz where appropriate.
|
||||
if va_likely and album:
|
||||
yield from invoke_mb(
|
||||
mb.match_album, None, album, len(items), extra_tags
|
||||
)
|
||||
|
||||
# Candidates from plugins.
|
||||
yield from plugins.candidates(items, artist, album, va_likely, extra_tags)
|
||||
|
||||
|
||||
@plugins.notify_info_yielded("trackinfo_received")
|
||||
def item_candidates(item: Item, artist: str, title: str) -> Iterable[tuple]:
|
||||
"""Search for item matches. ``item`` is the Item to be matched.
|
||||
``artist`` and ``title`` are strings and either reflect the item or
|
||||
are specified by the user.
|
||||
"""
|
||||
|
||||
# MusicBrainz candidates.
|
||||
if config["musicbrainz"]["enabled"] and artist and title:
|
||||
yield from invoke_mb(mb.match_track, artist, title)
|
||||
|
||||
# Plugin candidates.
|
||||
yield from plugins.item_candidates(item, artist, title)
|
||||
|
|
|
|||
|
|
@ -18,35 +18,22 @@ releases and tracks.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from collections.abc import Iterable, Sequence
|
||||
from enum import IntEnum
|
||||
from functools import cache
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
|
||||
|
||||
import lap
|
||||
import numpy as np
|
||||
|
||||
from beets import config, logging, plugins
|
||||
from beets.autotag import (
|
||||
AlbumInfo,
|
||||
AlbumMatch,
|
||||
Distance,
|
||||
TrackInfo,
|
||||
TrackMatch,
|
||||
hooks,
|
||||
)
|
||||
from beets.util import plurality
|
||||
from beets import config, logging, metadata_plugins, plugins
|
||||
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
|
||||
from beets.util import get_most_common_tags
|
||||
|
||||
from .distance import VA_ARTISTS, distance, track_distance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.library import Item
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
# Artist signals that indicate "various artists". These are used at the
|
||||
# album level to determine whether a given release is likely a VA
|
||||
# release and also on the track level to to remove the penalty for
|
||||
# differing artists.
|
||||
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
|
||||
from beets.library import Item
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
|
|
@ -79,48 +66,10 @@ class Proposal(NamedTuple):
|
|||
# Primary matching functionality.
|
||||
|
||||
|
||||
def current_metadata(
|
||||
items: Iterable[Item],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract the likely current metadata for an album given a list of its
|
||||
items. Return two dictionaries:
|
||||
- The most common value for each field.
|
||||
- Whether each field's value was unanimous (values are booleans).
|
||||
"""
|
||||
assert items # Must be nonempty.
|
||||
|
||||
likelies = {}
|
||||
consensus = {}
|
||||
fields = [
|
||||
"artist",
|
||||
"album",
|
||||
"albumartist",
|
||||
"year",
|
||||
"disctotal",
|
||||
"mb_albumid",
|
||||
"label",
|
||||
"barcode",
|
||||
"catalognum",
|
||||
"country",
|
||||
"media",
|
||||
"albumdisambig",
|
||||
]
|
||||
for field in fields:
|
||||
values = [item[field] for item in items if item]
|
||||
likelies[field], freq = plurality(values)
|
||||
consensus[field] = freq == len(values)
|
||||
|
||||
# If there's an album artist consensus, use this for the artist.
|
||||
if consensus["albumartist"] and likelies["albumartist"]:
|
||||
likelies["artist"] = likelies["albumartist"]
|
||||
|
||||
return likelies, consensus
|
||||
|
||||
|
||||
def assign_items(
|
||||
items: Sequence[Item],
|
||||
tracks: Sequence[TrackInfo],
|
||||
) -> tuple[dict[Item, TrackInfo], list[Item], list[TrackInfo]]:
|
||||
) -> tuple[list[tuple[Item, TrackInfo]], list[Item], list[TrackInfo]]:
|
||||
"""Given a list of Items and a list of TrackInfo objects, find the
|
||||
best mapping between them. Returns a mapping from Items to TrackInfo
|
||||
objects, a set of extra Items, and a set of extra TrackInfo
|
||||
|
|
@ -146,195 +95,11 @@ def assign_items(
|
|||
extra_items.sort(key=lambda i: (i.disc, i.track, i.title))
|
||||
extra_tracks = list(set(tracks) - set(mapping.values()))
|
||||
extra_tracks.sort(key=lambda t: (t.index, t.title))
|
||||
return mapping, extra_items, extra_tracks
|
||||
return list(mapping.items()), extra_items, extra_tracks
|
||||
|
||||
|
||||
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
|
||||
"""Returns True if the item and track info index is different. Tolerates
|
||||
per disc and per release numbering.
|
||||
"""
|
||||
return item.track not in (track_info.medium_index, track_info.index)
|
||||
|
||||
|
||||
@cache
|
||||
def get_track_length_grace() -> float:
|
||||
"""Get cached grace period for track length matching."""
|
||||
return config["match"]["track_length_grace"].as_number()
|
||||
|
||||
|
||||
@cache
|
||||
def get_track_length_max() -> float:
|
||||
"""Get cached maximum track length for track length matching."""
|
||||
return config["match"]["track_length_max"].as_number()
|
||||
|
||||
|
||||
def track_distance(
|
||||
item: Item,
|
||||
track_info: TrackInfo,
|
||||
incl_artist: bool = False,
|
||||
) -> Distance:
|
||||
"""Determines the significance of a track metadata change. Returns a
|
||||
Distance object. `incl_artist` indicates that a distance component should
|
||||
be included for the track artist (i.e., for various-artist releases).
|
||||
|
||||
``track_length_grace`` and ``track_length_max`` configuration options are
|
||||
cached because this function is called many times during the matching
|
||||
process and their access comes with a performance overhead.
|
||||
"""
|
||||
dist = hooks.Distance()
|
||||
|
||||
# Length.
|
||||
if info_length := track_info.length:
|
||||
diff = abs(item.length - info_length) - get_track_length_grace()
|
||||
dist.add_ratio("track_length", diff, get_track_length_max())
|
||||
|
||||
# Title.
|
||||
dist.add_string("track_title", item.title, track_info.title)
|
||||
|
||||
# Artist. Only check if there is actually an artist in the track data.
|
||||
if (
|
||||
incl_artist
|
||||
and track_info.artist
|
||||
and item.artist.lower() not in VA_ARTISTS
|
||||
):
|
||||
dist.add_string("track_artist", item.artist, track_info.artist)
|
||||
|
||||
# Track index.
|
||||
if track_info.index and item.track:
|
||||
dist.add_expr("track_index", track_index_changed(item, track_info))
|
||||
|
||||
# Track ID.
|
||||
if item.mb_trackid:
|
||||
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
|
||||
|
||||
# Penalize mismatching disc numbers.
|
||||
if track_info.medium and item.disc:
|
||||
dist.add_expr("medium", item.disc != track_info.medium)
|
||||
|
||||
# Plugins.
|
||||
dist.update(plugins.track_distance(item, track_info))
|
||||
|
||||
return dist
|
||||
|
||||
|
||||
def distance(
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
"""Determines how "significant" an album metadata change would be.
|
||||
Returns a Distance object. `album_info` is an AlbumInfo object
|
||||
reflecting the album to be compared. `items` is a sequence of all
|
||||
Item objects that will be matched (order is not important).
|
||||
`mapping` is a dictionary mapping Items to TrackInfo objects; the
|
||||
keys are a subset of `items` and the values are a subset of
|
||||
`album_info.tracks`.
|
||||
"""
|
||||
likelies, _ = current_metadata(items)
|
||||
|
||||
dist = hooks.Distance()
|
||||
|
||||
# Artist, if not various.
|
||||
if not album_info.va:
|
||||
dist.add_string("artist", likelies["artist"], album_info.artist)
|
||||
|
||||
# Album.
|
||||
dist.add_string("album", likelies["album"], album_info.album)
|
||||
|
||||
# Current or preferred media.
|
||||
if album_info.media:
|
||||
# Preferred media options.
|
||||
patterns = config["match"]["preferred"]["media"].as_str_seq()
|
||||
patterns = cast(Sequence[str], patterns)
|
||||
options = [re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in patterns]
|
||||
if options:
|
||||
dist.add_priority("media", album_info.media, options)
|
||||
# Current media.
|
||||
elif likelies["media"]:
|
||||
dist.add_equality("media", album_info.media, likelies["media"])
|
||||
|
||||
# Mediums.
|
||||
if likelies["disctotal"] and album_info.mediums:
|
||||
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
|
||||
|
||||
# Prefer earliest release.
|
||||
if album_info.year and config["match"]["preferred"]["original_year"]:
|
||||
# Assume 1889 (earliest first gramophone discs) if we don't know the
|
||||
# original year.
|
||||
original = album_info.original_year or 1889
|
||||
diff = abs(album_info.year - original)
|
||||
diff_max = abs(datetime.date.today().year - original)
|
||||
dist.add_ratio("year", diff, diff_max)
|
||||
# Year.
|
||||
elif likelies["year"] and album_info.year:
|
||||
if likelies["year"] in (album_info.year, album_info.original_year):
|
||||
# No penalty for matching release or original year.
|
||||
dist.add("year", 0.0)
|
||||
elif album_info.original_year:
|
||||
# Prefer matchest closest to the release year.
|
||||
diff = abs(likelies["year"] - album_info.year)
|
||||
diff_max = abs(
|
||||
datetime.date.today().year - album_info.original_year
|
||||
)
|
||||
dist.add_ratio("year", diff, diff_max)
|
||||
else:
|
||||
# Full penalty when there is no original year.
|
||||
dist.add("year", 1.0)
|
||||
|
||||
# Preferred countries.
|
||||
patterns = config["match"]["preferred"]["countries"].as_str_seq()
|
||||
patterns = cast(Sequence[str], patterns)
|
||||
options = [re.compile(pat, re.I) for pat in patterns]
|
||||
if album_info.country and options:
|
||||
dist.add_priority("country", album_info.country, options)
|
||||
# Country.
|
||||
elif likelies["country"] and album_info.country:
|
||||
dist.add_string("country", likelies["country"], album_info.country)
|
||||
|
||||
# Label.
|
||||
if likelies["label"] and album_info.label:
|
||||
dist.add_string("label", likelies["label"], album_info.label)
|
||||
|
||||
# Catalog number.
|
||||
if likelies["catalognum"] and album_info.catalognum:
|
||||
dist.add_string(
|
||||
"catalognum", likelies["catalognum"], album_info.catalognum
|
||||
)
|
||||
|
||||
# Disambiguation.
|
||||
if likelies["albumdisambig"] and album_info.albumdisambig:
|
||||
dist.add_string(
|
||||
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
|
||||
)
|
||||
|
||||
# Album ID.
|
||||
if likelies["mb_albumid"]:
|
||||
dist.add_equality(
|
||||
"album_id", likelies["mb_albumid"], album_info.album_id
|
||||
)
|
||||
|
||||
# Tracks.
|
||||
dist.tracks = {}
|
||||
for item, track in mapping.items():
|
||||
dist.tracks[track] = track_distance(item, track, album_info.va)
|
||||
dist.add("tracks", dist.tracks[track].distance)
|
||||
|
||||
# Missing tracks.
|
||||
for _ in range(len(album_info.tracks) - len(mapping)):
|
||||
dist.add("missing_tracks", 1.0)
|
||||
|
||||
# Unmatched tracks.
|
||||
for _ in range(len(items) - len(mapping)):
|
||||
dist.add("unmatched_tracks", 1.0)
|
||||
|
||||
# Plugins.
|
||||
dist.update(plugins.album_distance(items, album_info, mapping))
|
||||
|
||||
return dist
|
||||
|
||||
|
||||
def match_by_id(items: Iterable[Item]):
|
||||
"""If the items are tagged with a MusicBrainz album ID, returns an
|
||||
def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
|
||||
"""If the items are tagged with an external source ID, return an
|
||||
AlbumInfo object for the corresponding album. Otherwise, returns
|
||||
None.
|
||||
"""
|
||||
|
|
@ -353,8 +118,8 @@ def match_by_id(items: Iterable[Item]):
|
|||
log.debug("No album ID consensus.")
|
||||
return None
|
||||
# If all album IDs are equal, look up the album.
|
||||
log.debug("Searching for discovered album ID: {0}", first)
|
||||
return hooks.album_for_mbid(first)
|
||||
log.debug("Searching for discovered album ID: {}", first)
|
||||
return metadata_plugins.album_for_id(first)
|
||||
|
||||
|
||||
def _recommendation(
|
||||
|
|
@ -432,9 +197,7 @@ def _add_candidate(
|
|||
checking the track count, ordering the items, checking for
|
||||
duplicates, and calculating the distance.
|
||||
"""
|
||||
log.debug(
|
||||
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
|
||||
)
|
||||
log.debug("Candidate: {0.artist} - {0.album} ({0.album_id})", info)
|
||||
|
||||
# Discard albums with zero tracks.
|
||||
if not info.tracks:
|
||||
|
|
@ -447,37 +210,38 @@ def _add_candidate(
|
|||
return
|
||||
|
||||
# Discard matches without required tags.
|
||||
for req_tag in cast(
|
||||
Sequence[str], config["match"]["required"].as_str_seq()
|
||||
):
|
||||
required_tags: Sequence[str] = config["match"]["required"].as_str_seq()
|
||||
for req_tag in required_tags:
|
||||
if getattr(info, req_tag) is None:
|
||||
log.debug("Ignored. Missing required tag: {0}", req_tag)
|
||||
log.debug("Ignored. Missing required tag: {}", req_tag)
|
||||
return
|
||||
|
||||
# Find mapping between the items and the track info.
|
||||
mapping, extra_items, extra_tracks = assign_items(items, info.tracks)
|
||||
item_info_pairs, extra_items, extra_tracks = assign_items(
|
||||
items, info.tracks
|
||||
)
|
||||
|
||||
# Get the change distance.
|
||||
dist = distance(items, info, mapping)
|
||||
dist = distance(items, info, item_info_pairs)
|
||||
|
||||
# Skip matches with ignored penalties.
|
||||
penalties = [key for key, _ in dist]
|
||||
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq())
|
||||
for penalty in ignored:
|
||||
ignored_tags: Sequence[str] = config["match"]["ignored"].as_str_seq()
|
||||
for penalty in ignored_tags:
|
||||
if penalty in penalties:
|
||||
log.debug("Ignored. Penalty: {0}", penalty)
|
||||
log.debug("Ignored. Penalty: {}", penalty)
|
||||
return
|
||||
|
||||
log.debug("Success. Distance: {0}", dist)
|
||||
log.debug("Success. Distance: {}", dist)
|
||||
results[info.album_id] = hooks.AlbumMatch(
|
||||
dist, info, mapping, extra_items, extra_tracks
|
||||
dist, info, dict(item_info_pairs), extra_items, extra_tracks
|
||||
)
|
||||
|
||||
|
||||
def tag_album(
|
||||
items,
|
||||
search_artist: str | None = None,
|
||||
search_album: str | None = None,
|
||||
search_name: str | None = None,
|
||||
search_ids: list[str] = [],
|
||||
) -> tuple[str, str, Proposal]:
|
||||
"""Return a tuple of the current artist name, the current album
|
||||
|
|
@ -498,10 +262,10 @@ def tag_album(
|
|||
candidates.
|
||||
"""
|
||||
# Get current metadata.
|
||||
likelies, consensus = current_metadata(items)
|
||||
cur_artist = cast(str, likelies["artist"])
|
||||
cur_album = cast(str, likelies["album"])
|
||||
log.debug("Tagging {0} - {1}", cur_artist, cur_album)
|
||||
likelies, consensus = get_most_common_tags(items)
|
||||
cur_artist: str = likelies["artist"]
|
||||
cur_album: str = likelies["album"]
|
||||
log.debug("Tagging {} - {}", cur_artist, cur_album)
|
||||
|
||||
# The output result, keys are the MB album ID.
|
||||
candidates: dict[Any, AlbumMatch] = {}
|
||||
|
|
@ -509,18 +273,22 @@ def tag_album(
|
|||
# Search by explicit ID.
|
||||
if search_ids:
|
||||
for search_id in search_ids:
|
||||
log.debug("Searching for album ID: {0}", search_id)
|
||||
if info := hooks.album_for_id(search_id):
|
||||
log.debug("Searching for album ID: {}", search_id)
|
||||
if info := metadata_plugins.album_for_id(search_id):
|
||||
_add_candidate(items, candidates, info)
|
||||
if opt_candidate := candidates.get(info.album_id):
|
||||
plugins.send("album_matched", match=opt_candidate)
|
||||
|
||||
# Use existing metadata or text search.
|
||||
else:
|
||||
# Try search based on current ID.
|
||||
id_info = match_by_id(items)
|
||||
if id_info:
|
||||
_add_candidate(items, candidates, id_info)
|
||||
if info := match_by_id(items):
|
||||
_add_candidate(items, candidates, info)
|
||||
for candidate in candidates.values():
|
||||
plugins.send("album_matched", match=candidate)
|
||||
|
||||
rec = _recommendation(list(candidates.values()))
|
||||
log.debug("Album ID match recommendation is {0}", rec)
|
||||
log.debug("Album ID match recommendation is {}", rec)
|
||||
if candidates and not config["import"]["timid"]:
|
||||
# If we have a very good MBID match, return immediately.
|
||||
# Otherwise, this match will compete against metadata-based
|
||||
|
|
@ -534,16 +302,10 @@ def tag_album(
|
|||
)
|
||||
|
||||
# Search terms.
|
||||
if not (search_artist and search_album):
|
||||
if not (search_artist and search_name):
|
||||
# No explicit search terms -- use current metadata.
|
||||
search_artist, search_album = cur_artist, cur_album
|
||||
log.debug("Search terms: {0} - {1}", search_artist, search_album)
|
||||
|
||||
extra_tags = None
|
||||
if config["musicbrainz"]["extra_tags"]:
|
||||
tag_list = config["musicbrainz"]["extra_tags"].get()
|
||||
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
|
||||
log.debug("Additional search terms: {0}", extra_tags)
|
||||
search_artist, search_name = cur_artist, cur_album
|
||||
log.debug("Search terms: {} - {}", search_artist, search_name)
|
||||
|
||||
# Is this album likely to be a "various artist" release?
|
||||
va_likely = (
|
||||
|
|
@ -551,15 +313,17 @@ def tag_album(
|
|||
or (search_artist.lower() in VA_ARTISTS)
|
||||
or any(item.comp for item in items)
|
||||
)
|
||||
log.debug("Album might be VA: {0}", va_likely)
|
||||
log.debug("Album might be VA: {}", va_likely)
|
||||
|
||||
# Get the results from the data sources.
|
||||
for matched_candidate in hooks.album_candidates(
|
||||
items, search_artist, search_album, va_likely, extra_tags
|
||||
for matched_candidate in metadata_plugins.candidates(
|
||||
items, search_artist, search_name, va_likely
|
||||
):
|
||||
_add_candidate(items, candidates, matched_candidate)
|
||||
if opt_candidate := candidates.get(matched_candidate.album_id):
|
||||
plugins.send("album_matched", match=opt_candidate)
|
||||
|
||||
log.debug("Evaluating {0} candidates.", len(candidates))
|
||||
log.debug("Evaluating {} candidates.", len(candidates))
|
||||
# Sort and get the recommendation.
|
||||
candidates_sorted = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates_sorted)
|
||||
|
|
@ -569,28 +333,27 @@ def tag_album(
|
|||
def tag_item(
|
||||
item,
|
||||
search_artist: str | None = None,
|
||||
search_title: str | None = None,
|
||||
search_name: str | None = None,
|
||||
search_ids: list[str] | None = None,
|
||||
) -> Proposal:
|
||||
"""Find metadata for a single track. Return a `Proposal` consisting
|
||||
of `TrackMatch` objects.
|
||||
|
||||
`search_artist` and `search_title` may be used
|
||||
to override the current metadata for the purposes of the MusicBrainz
|
||||
title. `search_ids` may be used for restricting the search to a list
|
||||
of metadata backend IDs.
|
||||
`search_artist` and `search_title` may be used to override the item
|
||||
metadata in the search query. `search_ids` may be used for restricting the
|
||||
search to a list of metadata backend IDs.
|
||||
"""
|
||||
# Holds candidates found so far: keys are MBIDs; values are
|
||||
# (distance, TrackInfo) pairs.
|
||||
candidates = {}
|
||||
rec: Recommendation | None = None
|
||||
|
||||
# First, try matching by MusicBrainz ID.
|
||||
# First, try matching by the external source ID.
|
||||
trackids = search_ids or [t for t in [item.mb_trackid] if t]
|
||||
if trackids:
|
||||
for trackid in trackids:
|
||||
log.debug("Searching for track ID: {0}", trackid)
|
||||
if info := hooks.track_for_id(trackid):
|
||||
log.debug("Searching for track ID: {}", trackid)
|
||||
if info := metadata_plugins.track_for_id(trackid):
|
||||
dist = track_distance(item, info, incl_artist=True)
|
||||
candidates[info.track_id] = hooks.TrackMatch(dist, info)
|
||||
# If this is a good match, then don't keep searching.
|
||||
|
|
@ -611,17 +374,19 @@ def tag_item(
|
|||
return Proposal([], Recommendation.none)
|
||||
|
||||
# Search terms.
|
||||
if not (search_artist and search_title):
|
||||
search_artist, search_title = item.artist, item.title
|
||||
log.debug("Item search terms: {0} - {1}", search_artist, search_title)
|
||||
search_artist = search_artist or item.artist
|
||||
search_name = search_name or item.title
|
||||
log.debug("Item search terms: {} - {}", search_artist, search_name)
|
||||
|
||||
# Get and evaluate candidate metadata.
|
||||
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
||||
for track_info in metadata_plugins.item_candidates(
|
||||
item, search_artist, search_name
|
||||
):
|
||||
dist = track_distance(item, track_info, incl_artist=True)
|
||||
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
||||
|
||||
# Sort by distance and return with recommendation.
|
||||
log.debug("Found {0} candidates.", len(candidates))
|
||||
log.debug("Found {} candidates.", len(candidates))
|
||||
candidates_sorted = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates_sorted)
|
||||
return Proposal(candidates_sorted, rec)
|
||||
|
|
|
|||
|
|
@ -1,884 +0,0 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Searches for albums in the MusicBrainz database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import traceback
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator, Sequence
|
||||
from itertools import product
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import musicbrainzngs
|
||||
|
||||
import beets
|
||||
import beets.autotag.hooks
|
||||
from beets import config, logging, plugins, util
|
||||
from beets.plugins import MetadataSourcePlugin
|
||||
from beets.util.id_extractors import (
|
||||
beatport_id_regex,
|
||||
deezer_id_regex,
|
||||
extract_discogs_id_regex,
|
||||
spotify_id_regex,
|
||||
)
|
||||
|
||||
VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
BASE_URL = "https://musicbrainz.org/"
|
||||
|
||||
SKIPPED_TRACKS = ["[data track]"]
|
||||
|
||||
FIELDS_TO_MB_KEYS = {
|
||||
"catalognum": "catno",
|
||||
"country": "country",
|
||||
"label": "label",
|
||||
"barcode": "barcode",
|
||||
"media": "format",
|
||||
"year": "date",
|
||||
}
|
||||
|
||||
musicbrainzngs.set_useragent("beets", beets.__version__, "https://beets.io/")
|
||||
|
||||
|
||||
class MusicBrainzAPIError(util.HumanReadableError):
|
||||
"""An error while talking to MusicBrainz. The `query` field is the
|
||||
parameter to the action and may have any type.
|
||||
"""
|
||||
|
||||
def __init__(self, reason, verb, query, tb=None):
|
||||
self.query = query
|
||||
if isinstance(reason, musicbrainzngs.WebServiceError):
|
||||
reason = "MusicBrainz not reachable"
|
||||
super().__init__(reason, verb, tb)
|
||||
|
||||
def get_message(self):
|
||||
return "{} in {} with query {}".format(
|
||||
self._reasonstr(), self.verb, repr(self.query)
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
RELEASE_INCLUDES = list(
|
||||
{
|
||||
"artists",
|
||||
"media",
|
||||
"recordings",
|
||||
"release-groups",
|
||||
"labels",
|
||||
"artist-credits",
|
||||
"aliases",
|
||||
"recording-level-rels",
|
||||
"work-rels",
|
||||
"work-level-rels",
|
||||
"artist-rels",
|
||||
"isrcs",
|
||||
"url-rels",
|
||||
"release-rels",
|
||||
"tags",
|
||||
}
|
||||
& set(musicbrainzngs.VALID_INCLUDES["release"])
|
||||
)
|
||||
|
||||
TRACK_INCLUDES = list(
|
||||
{
|
||||
"artists",
|
||||
"aliases",
|
||||
"isrcs",
|
||||
"work-level-rels",
|
||||
"artist-rels",
|
||||
}
|
||||
& set(musicbrainzngs.VALID_INCLUDES["recording"])
|
||||
)
|
||||
|
||||
BROWSE_INCLUDES = [
|
||||
"artist-credits",
|
||||
"work-rels",
|
||||
"artist-rels",
|
||||
"recording-rels",
|
||||
"release-rels",
|
||||
]
|
||||
if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES["recording"]:
|
||||
BROWSE_INCLUDES.append("work-level-rels")
|
||||
BROWSE_CHUNKSIZE = 100
|
||||
BROWSE_MAXTRACKS = 500
|
||||
|
||||
|
||||
def track_url(trackid: str) -> str:
|
||||
return urljoin(BASE_URL, "recording/" + trackid)
|
||||
|
||||
|
||||
def album_url(albumid: str) -> str:
|
||||
return urljoin(BASE_URL, "release/" + albumid)
|
||||
|
||||
|
||||
def configure():
|
||||
"""Set up the python-musicbrainz-ngs module according to settings
|
||||
from the beets configuration. This should be called at startup.
|
||||
"""
|
||||
hostname = config["musicbrainz"]["host"].as_str()
|
||||
https = config["musicbrainz"]["https"].get(bool)
|
||||
# Only call set_hostname when a custom server is configured. Since
|
||||
# musicbrainz-ngs connects to musicbrainz.org with HTTPS by default
|
||||
if hostname != "musicbrainz.org":
|
||||
musicbrainzngs.set_hostname(hostname, https)
|
||||
musicbrainzngs.set_rate_limit(
|
||||
config["musicbrainz"]["ratelimit_interval"].as_number(),
|
||||
config["musicbrainz"]["ratelimit"].get(int),
|
||||
)
|
||||
|
||||
|
||||
def _preferred_alias(aliases: list):
|
||||
"""Given an list of alias structures for an artist credit, select
|
||||
and return the user's preferred alias alias or None if no matching
|
||||
alias is found.
|
||||
"""
|
||||
if not aliases:
|
||||
return
|
||||
|
||||
# Only consider aliases that have locales set.
|
||||
aliases = [a for a in aliases if "locale" in a]
|
||||
|
||||
# Get any ignored alias types and lower case them to prevent case issues
|
||||
ignored_alias_types = config["import"]["ignored_alias_types"].as_str_seq()
|
||||
ignored_alias_types = [a.lower() for a in ignored_alias_types]
|
||||
|
||||
# Search configured locales in order.
|
||||
for locale in config["import"]["languages"].as_str_seq():
|
||||
# Find matching primary aliases for this locale that are not
|
||||
# being ignored
|
||||
matches = []
|
||||
for a in aliases:
|
||||
if (
|
||||
a["locale"] == locale
|
||||
and "primary" in a
|
||||
and a.get("type", "").lower() not in ignored_alias_types
|
||||
):
|
||||
matches.append(a)
|
||||
|
||||
# Skip to the next locale if we have no matches
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _preferred_release_event(release: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Given a release, select and return the user's preferred release
|
||||
event as a tuple of (country, release_date). Fall back to the
|
||||
default release event if a preferred event is not found.
|
||||
"""
|
||||
countries = config["match"]["preferred"]["countries"].as_str_seq()
|
||||
countries = cast(Sequence, countries)
|
||||
|
||||
for country in countries:
|
||||
for event in release.get("release-event-list", {}):
|
||||
try:
|
||||
if country in event["area"]["iso-3166-1-code-list"]:
|
||||
return country, event["date"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return (cast(str, release.get("country")), cast(str, release.get("date")))
|
||||
|
||||
|
||||
def _multi_artist_credit(
|
||||
credit: list[dict], include_join_phrase: bool
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Given a list representing an ``artist-credit`` block, accumulate
|
||||
data into a triple of joined artist name lists: canonical, sort, and
|
||||
credit.
|
||||
"""
|
||||
artist_parts = []
|
||||
artist_sort_parts = []
|
||||
artist_credit_parts = []
|
||||
for el in credit:
|
||||
if isinstance(el, str):
|
||||
# Join phrase.
|
||||
if include_join_phrase:
|
||||
artist_parts.append(el)
|
||||
artist_credit_parts.append(el)
|
||||
artist_sort_parts.append(el)
|
||||
|
||||
else:
|
||||
alias = _preferred_alias(el["artist"].get("alias-list", ()))
|
||||
|
||||
# An artist.
|
||||
if alias:
|
||||
cur_artist_name = alias["alias"]
|
||||
else:
|
||||
cur_artist_name = el["artist"]["name"]
|
||||
artist_parts.append(cur_artist_name)
|
||||
|
||||
# Artist sort name.
|
||||
if alias:
|
||||
artist_sort_parts.append(alias["sort-name"])
|
||||
elif "sort-name" in el["artist"]:
|
||||
artist_sort_parts.append(el["artist"]["sort-name"])
|
||||
else:
|
||||
artist_sort_parts.append(cur_artist_name)
|
||||
|
||||
# Artist credit.
|
||||
if "name" in el:
|
||||
artist_credit_parts.append(el["name"])
|
||||
else:
|
||||
artist_credit_parts.append(cur_artist_name)
|
||||
|
||||
return (
|
||||
artist_parts,
|
||||
artist_sort_parts,
|
||||
artist_credit_parts,
|
||||
)
|
||||
|
||||
|
||||
def _flatten_artist_credit(credit: list[dict]) -> tuple[str, str, str]:
|
||||
"""Given a list representing an ``artist-credit`` block, flatten the
|
||||
data into a triple of joined artist name strings: canonical, sort, and
|
||||
credit.
|
||||
"""
|
||||
artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit(
|
||||
credit, include_join_phrase=True
|
||||
)
|
||||
return (
|
||||
"".join(artist_parts),
|
||||
"".join(artist_sort_parts),
|
||||
"".join(artist_credit_parts),
|
||||
)
|
||||
|
||||
|
||||
def _artist_ids(credit: list[dict]) -> list[str]:
|
||||
"""
|
||||
Given a list representing an ``artist-credit``,
|
||||
return a list of artist IDs
|
||||
"""
|
||||
artist_ids: list[str] = []
|
||||
for el in credit:
|
||||
if isinstance(el, dict):
|
||||
artist_ids.append(el["artist"]["id"])
|
||||
|
||||
return artist_ids
|
||||
|
||||
|
||||
def _get_related_artist_names(relations, relation_type):
|
||||
"""Given a list representing the artist relationships extract the names of
|
||||
the remixers and concatenate them.
|
||||
"""
|
||||
related_artists = []
|
||||
|
||||
for relation in relations:
|
||||
if relation["type"] == relation_type:
|
||||
related_artists.append(relation["artist"]["name"])
|
||||
|
||||
return ", ".join(related_artists)
|
||||
|
||||
|
||||
def track_info(
|
||||
recording: dict,
|
||||
index: int | None = None,
|
||||
medium: int | None = None,
|
||||
medium_index: int | None = None,
|
||||
medium_total: int | None = None,
|
||||
) -> beets.autotag.hooks.TrackInfo:
|
||||
"""Translates a MusicBrainz recording result dictionary into a beets
|
||||
``TrackInfo`` object. Three parameters are optional and are used
|
||||
only for tracks that appear on releases (non-singletons): ``index``,
|
||||
the overall track number; ``medium``, the disc number;
|
||||
``medium_index``, the track's index on its medium; ``medium_total``,
|
||||
the number of tracks on the medium. Each number is a 1-based index.
|
||||
"""
|
||||
info = beets.autotag.hooks.TrackInfo(
|
||||
title=recording["title"],
|
||||
track_id=recording["id"],
|
||||
index=index,
|
||||
medium=medium,
|
||||
medium_index=medium_index,
|
||||
medium_total=medium_total,
|
||||
data_source="MusicBrainz",
|
||||
data_url=track_url(recording["id"]),
|
||||
)
|
||||
|
||||
if recording.get("artist-credit"):
|
||||
# Get the artist names.
|
||||
(
|
||||
info.artist,
|
||||
info.artist_sort,
|
||||
info.artist_credit,
|
||||
) = _flatten_artist_credit(recording["artist-credit"])
|
||||
|
||||
(
|
||||
info.artists,
|
||||
info.artists_sort,
|
||||
info.artists_credit,
|
||||
) = _multi_artist_credit(
|
||||
recording["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
info.artists_ids = _artist_ids(recording["artist-credit"])
|
||||
info.artist_id = info.artists_ids[0]
|
||||
|
||||
if recording.get("artist-relation-list"):
|
||||
info.remixer = _get_related_artist_names(
|
||||
recording["artist-relation-list"], relation_type="remixer"
|
||||
)
|
||||
|
||||
if recording.get("length"):
|
||||
info.length = int(recording["length"]) / 1000.0
|
||||
|
||||
info.trackdisambig = recording.get("disambiguation")
|
||||
|
||||
if recording.get("isrc-list"):
|
||||
info.isrc = ";".join(recording["isrc-list"])
|
||||
|
||||
lyricist = []
|
||||
composer = []
|
||||
composer_sort = []
|
||||
for work_relation in recording.get("work-relation-list", ()):
|
||||
if work_relation["type"] != "performance":
|
||||
continue
|
||||
info.work = work_relation["work"]["title"]
|
||||
info.mb_workid = work_relation["work"]["id"]
|
||||
if "disambiguation" in work_relation["work"]:
|
||||
info.work_disambig = work_relation["work"]["disambiguation"]
|
||||
|
||||
for artist_relation in work_relation["work"].get(
|
||||
"artist-relation-list", ()
|
||||
):
|
||||
if "type" in artist_relation:
|
||||
type = artist_relation["type"]
|
||||
if type == "lyricist":
|
||||
lyricist.append(artist_relation["artist"]["name"])
|
||||
elif type == "composer":
|
||||
composer.append(artist_relation["artist"]["name"])
|
||||
composer_sort.append(artist_relation["artist"]["sort-name"])
|
||||
if lyricist:
|
||||
info.lyricist = ", ".join(lyricist)
|
||||
if composer:
|
||||
info.composer = ", ".join(composer)
|
||||
info.composer_sort = ", ".join(composer_sort)
|
||||
|
||||
arranger = []
|
||||
for artist_relation in recording.get("artist-relation-list", ()):
|
||||
if "type" in artist_relation:
|
||||
type = artist_relation["type"]
|
||||
if type == "arranger":
|
||||
arranger.append(artist_relation["artist"]["name"])
|
||||
if arranger:
|
||||
info.arranger = ", ".join(arranger)
|
||||
|
||||
# Supplementary fields provided by plugins
|
||||
extra_trackdatas = plugins.send("mb_track_extract", data=recording)
|
||||
for extra_trackdata in extra_trackdatas:
|
||||
info.update(extra_trackdata)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _set_date_str(
|
||||
info: beets.autotag.hooks.AlbumInfo,
|
||||
date_str: str,
|
||||
original: bool = False,
|
||||
):
|
||||
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
|
||||
object, set the object's release date fields appropriately. If
|
||||
`original`, then set the original_year, etc., fields.
|
||||
"""
|
||||
if date_str:
|
||||
date_parts = date_str.split("-")
|
||||
for key in ("year", "month", "day"):
|
||||
if date_parts:
|
||||
date_part = date_parts.pop(0)
|
||||
try:
|
||||
date_num = int(date_part)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if original:
|
||||
key = "original_" + key
|
||||
setattr(info, key, date_num)
|
||||
|
||||
|
||||
def album_info(release: dict) -> beets.autotag.hooks.AlbumInfo:
|
||||
"""Takes a MusicBrainz release result dictionary and returns a beets
|
||||
AlbumInfo object containing the interesting data about that release.
|
||||
"""
|
||||
# Get artist name using join phrases.
|
||||
artist_name, artist_sort_name, artist_credit_name = _flatten_artist_credit(
|
||||
release["artist-credit"]
|
||||
)
|
||||
|
||||
(
|
||||
artists_names,
|
||||
artists_sort_names,
|
||||
artists_credit_names,
|
||||
) = _multi_artist_credit(
|
||||
release["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
ntracks = sum(len(m["track-list"]) for m in release["medium-list"])
|
||||
|
||||
# The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list'
|
||||
# when the release has more than 500 tracks. So we use browse_recordings
|
||||
# on chunks of tracks to recover the same information in this case.
|
||||
if ntracks > BROWSE_MAXTRACKS:
|
||||
log.debug("Album {} has too many tracks", release["id"])
|
||||
recording_list = []
|
||||
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
|
||||
log.debug("Retrieving tracks starting at {}", i)
|
||||
recording_list.extend(
|
||||
musicbrainzngs.browse_recordings(
|
||||
release=release["id"],
|
||||
limit=BROWSE_CHUNKSIZE,
|
||||
includes=BROWSE_INCLUDES,
|
||||
offset=i,
|
||||
)["recording-list"]
|
||||
)
|
||||
track_map = {r["id"]: r for r in recording_list}
|
||||
for medium in release["medium-list"]:
|
||||
for recording in medium["track-list"]:
|
||||
recording_info = track_map[recording["recording"]["id"]]
|
||||
recording["recording"] = recording_info
|
||||
|
||||
# Basic info.
|
||||
track_infos = []
|
||||
index = 0
|
||||
for medium in release["medium-list"]:
|
||||
disctitle = medium.get("title")
|
||||
format = medium.get("format")
|
||||
|
||||
if format in config["match"]["ignored_media"].as_str_seq():
|
||||
continue
|
||||
|
||||
all_tracks = medium["track-list"]
|
||||
if (
|
||||
"data-track-list" in medium
|
||||
and not config["match"]["ignore_data_tracks"]
|
||||
):
|
||||
all_tracks += medium["data-track-list"]
|
||||
track_count = len(all_tracks)
|
||||
|
||||
if "pregap" in medium:
|
||||
all_tracks.insert(0, medium["pregap"])
|
||||
|
||||
for track in all_tracks:
|
||||
if (
|
||||
"title" in track["recording"]
|
||||
and track["recording"]["title"] in SKIPPED_TRACKS
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
"video" in track["recording"]
|
||||
and track["recording"]["video"] == "true"
|
||||
and config["match"]["ignore_video_tracks"]
|
||||
):
|
||||
continue
|
||||
|
||||
# Basic information from the recording.
|
||||
index += 1
|
||||
ti = track_info(
|
||||
track["recording"],
|
||||
index,
|
||||
int(medium["position"]),
|
||||
int(track["position"]),
|
||||
track_count,
|
||||
)
|
||||
ti.release_track_id = track["id"]
|
||||
ti.disctitle = disctitle
|
||||
ti.media = format
|
||||
ti.track_alt = track["number"]
|
||||
|
||||
# Prefer track data, where present, over recording data.
|
||||
if track.get("title"):
|
||||
ti.title = track["title"]
|
||||
if track.get("artist-credit"):
|
||||
# Get the artist names.
|
||||
(
|
||||
ti.artist,
|
||||
ti.artist_sort,
|
||||
ti.artist_credit,
|
||||
) = _flatten_artist_credit(track["artist-credit"])
|
||||
|
||||
(
|
||||
ti.artists,
|
||||
ti.artists_sort,
|
||||
ti.artists_credit,
|
||||
) = _multi_artist_credit(
|
||||
track["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
ti.artists_ids = _artist_ids(track["artist-credit"])
|
||||
ti.artist_id = ti.artists_ids[0]
|
||||
if track.get("length"):
|
||||
ti.length = int(track["length"]) / (1000.0)
|
||||
|
||||
track_infos.append(ti)
|
||||
|
||||
album_artist_ids = _artist_ids(release["artist-credit"])
|
||||
info = beets.autotag.hooks.AlbumInfo(
|
||||
album=release["title"],
|
||||
album_id=release["id"],
|
||||
artist=artist_name,
|
||||
artist_id=album_artist_ids[0],
|
||||
artists=artists_names,
|
||||
artists_ids=album_artist_ids,
|
||||
tracks=track_infos,
|
||||
mediums=len(release["medium-list"]),
|
||||
artist_sort=artist_sort_name,
|
||||
artists_sort=artists_sort_names,
|
||||
artist_credit=artist_credit_name,
|
||||
artists_credit=artists_credit_names,
|
||||
data_source="MusicBrainz",
|
||||
data_url=album_url(release["id"]),
|
||||
barcode=release.get("barcode"),
|
||||
)
|
||||
info.va = info.artist_id == VARIOUS_ARTISTS_ID
|
||||
if info.va:
|
||||
info.artist = config["va_name"].as_str()
|
||||
info.asin = release.get("asin")
|
||||
info.releasegroup_id = release["release-group"]["id"]
|
||||
info.albumstatus = release.get("status")
|
||||
|
||||
if release["release-group"].get("title"):
|
||||
info.release_group_title = release["release-group"].get("title")
|
||||
|
||||
# Get the disambiguation strings at the release and release group level.
|
||||
if release["release-group"].get("disambiguation"):
|
||||
info.releasegroupdisambig = release["release-group"].get(
|
||||
"disambiguation"
|
||||
)
|
||||
if release.get("disambiguation"):
|
||||
info.albumdisambig = release.get("disambiguation")
|
||||
|
||||
# Get the "classic" Release type. This data comes from a legacy API
|
||||
# feature before MusicBrainz supported multiple release types.
|
||||
if "type" in release["release-group"]:
|
||||
reltype = release["release-group"]["type"]
|
||||
if reltype:
|
||||
info.albumtype = reltype.lower()
|
||||
|
||||
# Set the new-style "primary" and "secondary" release types.
|
||||
albumtypes = []
|
||||
if "primary-type" in release["release-group"]:
|
||||
rel_primarytype = release["release-group"]["primary-type"]
|
||||
if rel_primarytype:
|
||||
albumtypes.append(rel_primarytype.lower())
|
||||
if "secondary-type-list" in release["release-group"]:
|
||||
if release["release-group"]["secondary-type-list"]:
|
||||
for sec_type in release["release-group"]["secondary-type-list"]:
|
||||
albumtypes.append(sec_type.lower())
|
||||
info.albumtypes = albumtypes
|
||||
|
||||
# Release events.
|
||||
info.country, release_date = _preferred_release_event(release)
|
||||
release_group_date = release["release-group"].get("first-release-date")
|
||||
if not release_date:
|
||||
# Fall back if release-specific date is not available.
|
||||
release_date = release_group_date
|
||||
_set_date_str(info, release_date, False)
|
||||
_set_date_str(info, release_group_date, True)
|
||||
|
||||
# Label name.
|
||||
if release.get("label-info-list"):
|
||||
label_info = release["label-info-list"][0]
|
||||
if label_info.get("label"):
|
||||
label = label_info["label"]["name"]
|
||||
if label != "[no label]":
|
||||
info.label = label
|
||||
info.catalognum = label_info.get("catalog-number")
|
||||
|
||||
# Text representation data.
|
||||
if release.get("text-representation"):
|
||||
rep = release["text-representation"]
|
||||
info.script = rep.get("script")
|
||||
info.language = rep.get("language")
|
||||
|
||||
# Media (format).
|
||||
if release["medium-list"]:
|
||||
# If all media are the same, use that medium name
|
||||
if len({m.get("format") for m in release["medium-list"]}) == 1:
|
||||
info.media = release["medium-list"][0].get("format")
|
||||
# Otherwise, let's just call it "Media"
|
||||
else:
|
||||
info.media = "Media"
|
||||
|
||||
if config["musicbrainz"]["genres"]:
|
||||
sources = [
|
||||
release["release-group"].get("tag-list", []),
|
||||
release.get("tag-list", []),
|
||||
]
|
||||
genres: Counter[str] = Counter()
|
||||
for source in sources:
|
||||
for genreitem in source:
|
||||
genres[genreitem["name"]] += int(genreitem["count"])
|
||||
info.genre = "; ".join(
|
||||
genre
|
||||
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
|
||||
)
|
||||
|
||||
# We might find links to external sources (Discogs, Bandcamp, ...)
|
||||
external_ids = config["musicbrainz"]["external_ids"].get()
|
||||
wanted_sources = {site for site, wanted in external_ids.items() if wanted}
|
||||
if wanted_sources and (url_rels := release.get("url-relation-list")):
|
||||
urls = {}
|
||||
|
||||
for source, url in product(wanted_sources, url_rels):
|
||||
if f"{source}.com" in (target := url["target"]):
|
||||
urls[source] = target
|
||||
log.debug(
|
||||
"Found link to {} release via MusicBrainz",
|
||||
source.capitalize(),
|
||||
)
|
||||
|
||||
if "discogs" in urls:
|
||||
info.discogs_albumid = extract_discogs_id_regex(urls["discogs"])
|
||||
if "bandcamp" in urls:
|
||||
info.bandcamp_album_id = urls["bandcamp"]
|
||||
if "spotify" in urls:
|
||||
info.spotify_album_id = MetadataSourcePlugin._get_id(
|
||||
"album", urls["spotify"], spotify_id_regex
|
||||
)
|
||||
if "deezer" in urls:
|
||||
info.deezer_album_id = MetadataSourcePlugin._get_id(
|
||||
"album", urls["deezer"], deezer_id_regex
|
||||
)
|
||||
if "beatport" in urls:
|
||||
info.beatport_album_id = MetadataSourcePlugin._get_id(
|
||||
"album", urls["beatport"], beatport_id_regex
|
||||
)
|
||||
if "tidal" in urls:
|
||||
info.tidal_album_id = urls["tidal"].split("/")[-1]
|
||||
|
||||
extra_albumdatas = plugins.send("mb_album_extract", data=release)
|
||||
for extra_albumdata in extra_albumdatas:
|
||||
info.update(extra_albumdata)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def match_album(
|
||||
artist: str,
|
||||
album: str,
|
||||
tracks: int | None = None,
|
||||
extra_tags: dict[str, Any] | None = None,
|
||||
) -> Iterator[beets.autotag.hooks.AlbumInfo]:
|
||||
"""Searches for a single album ("release" in MusicBrainz parlance)
|
||||
and returns an iterator over AlbumInfo objects. May raise a
|
||||
MusicBrainzAPIError.
|
||||
|
||||
The query consists of an artist name, an album name, and,
|
||||
optionally, a number of tracks on the album and any other extra tags.
|
||||
"""
|
||||
# Build search criteria.
|
||||
criteria = {"release": album.lower().strip()}
|
||||
if artist is not None:
|
||||
criteria["artist"] = artist.lower().strip()
|
||||
else:
|
||||
# Various Artists search.
|
||||
criteria["arid"] = VARIOUS_ARTISTS_ID
|
||||
if tracks is not None:
|
||||
criteria["tracks"] = str(tracks)
|
||||
|
||||
# Additional search cues from existing metadata.
|
||||
if extra_tags:
|
||||
for tag, value in extra_tags.items():
|
||||
key = FIELDS_TO_MB_KEYS[tag]
|
||||
value = str(value).lower().strip()
|
||||
if key == "catno":
|
||||
value = value.replace(" ", "")
|
||||
if value:
|
||||
criteria[key] = value
|
||||
|
||||
# Abort if we have no search terms.
|
||||
if not any(criteria.values()):
|
||||
return
|
||||
|
||||
try:
|
||||
log.debug("Searching for MusicBrainz releases with: {!r}", criteria)
|
||||
res = musicbrainzngs.search_releases(
|
||||
limit=config["musicbrainz"]["searchlimit"].get(int), **criteria
|
||||
)
|
||||
except musicbrainzngs.MusicBrainzError as exc:
|
||||
raise MusicBrainzAPIError(
|
||||
exc, "release search", criteria, traceback.format_exc()
|
||||
)
|
||||
for release in res["release-list"]:
|
||||
# The search result is missing some data (namely, the tracks),
|
||||
# so we just use the ID and fetch the rest of the information.
|
||||
albuminfo = album_for_id(release["id"])
|
||||
if albuminfo is not None:
|
||||
yield albuminfo
|
||||
|
||||
|
||||
def match_track(
|
||||
artist: str,
|
||||
title: str,
|
||||
) -> Iterator[beets.autotag.hooks.TrackInfo]:
|
||||
"""Searches for a single track and returns an iterable of TrackInfo
|
||||
objects. May raise a MusicBrainzAPIError.
|
||||
"""
|
||||
criteria = {
|
||||
"artist": artist.lower().strip(),
|
||||
"recording": title.lower().strip(),
|
||||
}
|
||||
|
||||
if not any(criteria.values()):
|
||||
return
|
||||
|
||||
try:
|
||||
res = musicbrainzngs.search_recordings(
|
||||
limit=config["musicbrainz"]["searchlimit"].get(int), **criteria
|
||||
)
|
||||
except musicbrainzngs.MusicBrainzError as exc:
|
||||
raise MusicBrainzAPIError(
|
||||
exc, "recording search", criteria, traceback.format_exc()
|
||||
)
|
||||
for recording in res["recording-list"]:
|
||||
yield track_info(recording)
|
||||
|
||||
|
||||
def _parse_id(s: str) -> str | None:
|
||||
"""Search for a MusicBrainz ID in the given string and return it. If
|
||||
no ID can be found, return None.
|
||||
"""
|
||||
# Find the first thing that looks like a UUID/MBID.
|
||||
match = re.search("[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}", s)
|
||||
if match is not None:
|
||||
return match.group() if match else None
|
||||
return None
|
||||
|
||||
|
||||
def _is_translation(r):
|
||||
_trans_key = "transl-tracklisting"
|
||||
return r["type"] == _trans_key and r["direction"] == "backward"
|
||||
|
||||
|
||||
def _find_actual_release_from_pseudo_release(
|
||||
pseudo_rel: dict,
|
||||
) -> dict | None:
|
||||
try:
|
||||
relations = pseudo_rel["release"]["release-relation-list"]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# currently we only support trans(liter)ation's
|
||||
translations = [r for r in relations if _is_translation(r)]
|
||||
|
||||
if not translations:
|
||||
return None
|
||||
|
||||
actual_id = translations[0]["target"]
|
||||
|
||||
return musicbrainzngs.get_release_by_id(actual_id, RELEASE_INCLUDES)
|
||||
|
||||
|
||||
def _merge_pseudo_and_actual_album(
|
||||
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
|
||||
) -> beets.autotag.hooks.AlbumInfo | None:
|
||||
"""
|
||||
Merges a pseudo release with its actual release.
|
||||
|
||||
This implementation is naive, it doesn't overwrite fields,
|
||||
like status or ids.
|
||||
|
||||
According to the ticket PICARD-145, the main release id should be used.
|
||||
But the ticket has been in limbo since over a decade now.
|
||||
It also suggests the introduction of the tag `musicbrainz_pseudoreleaseid`,
|
||||
but as of this field can't be found in any official Picard docs,
|
||||
hence why we did not implement that for now.
|
||||
"""
|
||||
merged = pseudo.copy()
|
||||
from_actual = {
|
||||
k: actual[k]
|
||||
for k in [
|
||||
"media",
|
||||
"mediums",
|
||||
"country",
|
||||
"catalognum",
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"original_year",
|
||||
"original_month",
|
||||
"original_day",
|
||||
"label",
|
||||
"barcode",
|
||||
"asin",
|
||||
"style",
|
||||
"genre",
|
||||
]
|
||||
}
|
||||
merged.update(from_actual)
|
||||
return merged
|
||||
|
||||
|
||||
def album_for_id(releaseid: str) -> beets.autotag.hooks.AlbumInfo | None:
|
||||
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
|
||||
object or None if the album is not found. May raise a
|
||||
MusicBrainzAPIError.
|
||||
"""
|
||||
log.debug("Requesting MusicBrainz release {}", releaseid)
|
||||
albumid = _parse_id(releaseid)
|
||||
if not albumid:
|
||||
log.debug("Invalid MBID ({0}).", releaseid)
|
||||
return None
|
||||
try:
|
||||
res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES)
|
||||
|
||||
# resolve linked release relations
|
||||
actual_res = None
|
||||
|
||||
if res["release"].get("status") == "Pseudo-Release":
|
||||
actual_res = _find_actual_release_from_pseudo_release(res)
|
||||
|
||||
except musicbrainzngs.ResponseError:
|
||||
log.debug("Album ID match failed.")
|
||||
return None
|
||||
except musicbrainzngs.MusicBrainzError as exc:
|
||||
raise MusicBrainzAPIError(
|
||||
exc, "get release by ID", albumid, traceback.format_exc()
|
||||
)
|
||||
|
||||
# release is potentially a pseudo release
|
||||
release = album_info(res["release"])
|
||||
|
||||
# should be None unless we're dealing with a pseudo release
|
||||
if actual_res is not None:
|
||||
actual_release = album_info(actual_res["release"])
|
||||
return _merge_pseudo_and_actual_album(release, actual_release)
|
||||
else:
|
||||
return release
|
||||
|
||||
|
||||
def track_for_id(releaseid: str) -> beets.autotag.hooks.TrackInfo | None:
|
||||
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
|
||||
or None if no track is found. May raise a MusicBrainzAPIError.
|
||||
"""
|
||||
trackid = _parse_id(releaseid)
|
||||
if not trackid:
|
||||
log.debug("Invalid MBID ({0}).", releaseid)
|
||||
return None
|
||||
try:
|
||||
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
|
||||
except musicbrainzngs.ResponseError:
|
||||
log.debug("Track ID match failed.")
|
||||
return None
|
||||
except musicbrainzngs.MusicBrainzError as exc:
|
||||
raise MusicBrainzAPIError(
|
||||
exc, "get recording by ID", trackid, traceback.format_exc()
|
||||
)
|
||||
return track_info(res["recording"])
|
||||
|
|
@ -6,7 +6,8 @@ statefile: state.pickle
|
|||
|
||||
# --------------- Plugins ---------------
|
||||
|
||||
plugins: []
|
||||
plugins: [musicbrainz]
|
||||
|
||||
pluginpath: []
|
||||
|
||||
# --------------- Import ---------------
|
||||
|
|
@ -126,19 +127,12 @@ ui:
|
|||
action_default: ['bold', 'cyan']
|
||||
action: ['bold', 'cyan']
|
||||
# New Colors
|
||||
text: ['normal']
|
||||
text_faint: ['faint']
|
||||
import_path: ['bold', 'blue']
|
||||
import_path_items: ['bold', 'blue']
|
||||
added: ['green']
|
||||
removed: ['red']
|
||||
changed: ['yellow']
|
||||
added_highlight: ['bold', 'green']
|
||||
removed_highlight: ['bold', 'red']
|
||||
changed_highlight: ['bold', 'yellow']
|
||||
text_diff_added: ['bold', 'red']
|
||||
text_diff_added: ['bold', 'green']
|
||||
text_diff_removed: ['bold', 'red']
|
||||
text_diff_changed: ['bold', 'red']
|
||||
action_description: ['white']
|
||||
import:
|
||||
indentation:
|
||||
|
|
@ -163,22 +157,6 @@ sort_case_insensitive: yes
|
|||
overwrite_null:
|
||||
album: []
|
||||
track: []
|
||||
musicbrainz:
|
||||
enabled: yes
|
||||
host: musicbrainz.org
|
||||
https: no
|
||||
ratelimit: 1
|
||||
ratelimit_interval: 1.0
|
||||
searchlimit: 5
|
||||
extra_tags: []
|
||||
genres: no
|
||||
external_ids:
|
||||
discogs: no
|
||||
bandcamp: no
|
||||
spotify: no
|
||||
deezer: no
|
||||
beatport: no
|
||||
tidal: no
|
||||
|
||||
match:
|
||||
strong_rec_thresh: 0.04
|
||||
|
|
@ -188,7 +166,7 @@ match:
|
|||
missing_tracks: medium
|
||||
unmatched_tracks: medium
|
||||
distance_weights:
|
||||
source: 2.0
|
||||
data_source: 2.0
|
||||
artist: 3.0
|
||||
album: 3.0
|
||||
media: 1.0
|
||||
|
|
|
|||
|
|
@ -17,17 +17,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
|
||||
from sqlite3 import Connection
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Generic, TypeVar, cast
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
Generator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from functools import cached_property
|
||||
from sqlite3 import Connection, sqlite_version_info
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, Generic
|
||||
|
||||
from typing_extensions import (
|
||||
Self,
|
||||
TypeVar, # default value support
|
||||
)
|
||||
from unidecode import unidecode
|
||||
|
||||
import beets
|
||||
|
|
@ -49,10 +63,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from .query import SQLiteType
|
||||
|
||||
D = TypeVar("D", bound="Database", default=Any)
|
||||
else:
|
||||
D = TypeVar("D", bound="Database")
|
||||
|
||||
D = TypeVar("D", bound="Database", default=Any)
|
||||
|
||||
FlexAttrs = dict[str, str]
|
||||
|
||||
|
|
@ -66,6 +77,20 @@ class DBAccessError(Exception):
|
|||
"""
|
||||
|
||||
|
||||
class DBCustomFunctionError(Exception):
|
||||
"""A sqlite function registered by beets failed."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"beets defined SQLite function failed; "
|
||||
"see the other errors above for details"
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
class FormattedMapping(Mapping[str, str]):
|
||||
"""A `dict`-like formatted view of a model.
|
||||
|
||||
|
|
@ -80,6 +105,8 @@ class FormattedMapping(Mapping[str, str]):
|
|||
are replaced.
|
||||
"""
|
||||
|
||||
model: Model
|
||||
|
||||
ALL_KEYS = "*"
|
||||
|
||||
def __init__(
|
||||
|
|
@ -126,8 +153,8 @@ class FormattedMapping(Mapping[str, str]):
|
|||
value = value.decode("utf-8", "ignore")
|
||||
|
||||
if self.for_path:
|
||||
sep_repl = cast(str, beets.config["path_sep_replace"].as_str())
|
||||
sep_drive = cast(str, beets.config["drive_sep_replace"].as_str())
|
||||
sep_repl: str = beets.config["path_sep_replace"].as_str()
|
||||
sep_drive: str = beets.config["drive_sep_replace"].as_str()
|
||||
|
||||
if re.match(r"^\w:", value):
|
||||
value = re.sub(r"(?<=^\w):", sep_drive, value)
|
||||
|
|
@ -289,19 +316,22 @@ class Model(ABC, Generic[D]):
|
|||
terms.
|
||||
"""
|
||||
|
||||
_types: dict[str, types.Type] = {}
|
||||
"""Optional Types for non-fixed (i.e., flexible and computed) fields.
|
||||
"""
|
||||
@cached_classproperty
|
||||
def _types(cls) -> dict[str, types.Type]:
|
||||
"""Optional types for non-fixed (flexible and computed) fields."""
|
||||
return {}
|
||||
|
||||
_sorts: dict[str, type[FieldSort]] = {}
|
||||
"""Optional named sort criteria. The keys are strings and the values
|
||||
are subclasses of `Sort`.
|
||||
"""
|
||||
|
||||
_queries: dict[str, FieldQueryType] = {}
|
||||
"""Named queries that use a field-like `name:value` syntax but which
|
||||
do not relate to any specific field.
|
||||
"""
|
||||
@cached_classproperty
|
||||
def _queries(cls) -> dict[str, FieldQueryType]:
|
||||
"""Named queries that use a field-like `name:value` syntax but which
|
||||
do not relate to any specific field.
|
||||
"""
|
||||
return {}
|
||||
|
||||
_always_dirty = False
|
||||
"""By default, fields only become "dirty" when their value actually
|
||||
|
|
@ -340,6 +370,22 @@ class Model(ABC, Generic[D]):
|
|||
"""Fields in the related table."""
|
||||
return cls._relation._fields.keys() - cls.shared_db_fields
|
||||
|
||||
@cached_property
|
||||
def db(self) -> D:
|
||||
"""Get the database associated with this object.
|
||||
|
||||
This validates that the database is attached and the object has an id.
|
||||
"""
|
||||
return self._check_db()
|
||||
|
||||
def get_fresh_from_db(self) -> Self:
|
||||
"""Load this object from the database."""
|
||||
model_cls = self.__class__
|
||||
if obj := self.db._get(model_cls, self.id):
|
||||
return obj
|
||||
|
||||
raise NotFoundError(f"No matching {model_cls.__name__} found") from None
|
||||
|
||||
@classmethod
|
||||
def _getters(cls: type[Model]):
|
||||
"""Return a mapping from field names to getter functions."""
|
||||
|
|
@ -389,9 +435,9 @@ class Model(ABC, Generic[D]):
|
|||
return obj
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{}({})".format(
|
||||
type(self).__name__,
|
||||
", ".join(f"{k}={v!r}" for k, v in dict(self).items()),
|
||||
return (
|
||||
f"{type(self).__name__}"
|
||||
f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})"
|
||||
)
|
||||
|
||||
def clear_dirty(self):
|
||||
|
|
@ -408,9 +454,9 @@ class Model(ABC, Generic[D]):
|
|||
exception is raised otherwise.
|
||||
"""
|
||||
if not self._db:
|
||||
raise ValueError("{} has no database".format(type(self).__name__))
|
||||
raise ValueError(f"{type(self).__name__} has no database")
|
||||
if need_id and not self.id:
|
||||
raise ValueError("{} has no id".format(type(self).__name__))
|
||||
raise ValueError(f"{type(self).__name__} has no id")
|
||||
|
||||
return self._db
|
||||
|
||||
|
|
@ -579,7 +625,6 @@ class Model(ABC, Generic[D]):
|
|||
"""
|
||||
if fields is None:
|
||||
fields = self._fields
|
||||
db = self._check_db()
|
||||
|
||||
# Build assignments for query.
|
||||
assignments = []
|
||||
|
|
@ -587,16 +632,14 @@ class Model(ABC, Generic[D]):
|
|||
for key in fields:
|
||||
if key != "id" and key in self._dirty:
|
||||
self._dirty.remove(key)
|
||||
assignments.append(key + "=?")
|
||||
assignments.append(f"{key}=?")
|
||||
value = self._type(key).to_sql(self[key])
|
||||
subvars.append(value)
|
||||
|
||||
with db.transaction() as tx:
|
||||
with self.db.transaction() as tx:
|
||||
# Main table update.
|
||||
if assignments:
|
||||
query = "UPDATE {} SET {} WHERE id=?".format(
|
||||
self._table, ",".join(assignments)
|
||||
)
|
||||
query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?"
|
||||
subvars.append(self.id)
|
||||
tx.mutate(query, subvars)
|
||||
|
||||
|
|
@ -604,10 +647,11 @@ class Model(ABC, Generic[D]):
|
|||
for key, value in self._values_flex.items():
|
||||
if key in self._dirty:
|
||||
self._dirty.remove(key)
|
||||
value = self._type(key).to_sql(value)
|
||||
tx.mutate(
|
||||
"INSERT INTO {} "
|
||||
f"INSERT INTO {self._flex_table} "
|
||||
"(entity_id, key, value) "
|
||||
"VALUES (?, ?, ?);".format(self._flex_table),
|
||||
"VALUES (?, ?, ?);",
|
||||
(self.id, key, value),
|
||||
)
|
||||
|
||||
|
|
@ -626,21 +670,16 @@ class Model(ABC, Generic[D]):
|
|||
If check_revision is true, the database is only queried loaded when a
|
||||
transaction has been committed since the item was last loaded.
|
||||
"""
|
||||
db = self._check_db()
|
||||
if not self._dirty and db.revision == self._revision:
|
||||
if not self._dirty and self.db.revision == self._revision:
|
||||
# Exit early
|
||||
return
|
||||
stored_obj = db._get(type(self), self.id)
|
||||
assert stored_obj is not None, f"object {self.id} not in DB"
|
||||
self._values_fixed = LazyConvertDict(self)
|
||||
self._values_flex = LazyConvertDict(self)
|
||||
self.update(dict(stored_obj))
|
||||
|
||||
self.__dict__.update(self.get_fresh_from_db().__dict__)
|
||||
self.clear_dirty()
|
||||
|
||||
def remove(self):
|
||||
"""Remove the object's associated rows from the database."""
|
||||
db = self._check_db()
|
||||
with db.transaction() as tx:
|
||||
with self.db.transaction() as tx:
|
||||
tx.mutate(f"DELETE FROM {self._table} WHERE id=?", (self.id,))
|
||||
tx.mutate(
|
||||
f"DELETE FROM {self._flex_table} WHERE entity_id=?", (self.id,)
|
||||
|
|
@ -656,7 +695,7 @@ class Model(ABC, Generic[D]):
|
|||
"""
|
||||
if db:
|
||||
self._db = db
|
||||
db = self._check_db(False)
|
||||
db = self._check_db(need_id=False)
|
||||
|
||||
with db.transaction() as tx:
|
||||
new_id = tx.mutate(f"INSERT INTO {self._table} DEFAULT VALUES")
|
||||
|
|
@ -677,7 +716,7 @@ class Model(ABC, Generic[D]):
|
|||
self,
|
||||
included_keys: str = _formatter.ALL_KEYS,
|
||||
for_path: bool = False,
|
||||
):
|
||||
) -> FormattedMapping:
|
||||
"""Get a mapping containing all values on this object formatted
|
||||
as human-readable unicode strings.
|
||||
"""
|
||||
|
|
@ -721,9 +760,9 @@ class Model(ABC, Generic[D]):
|
|||
Remove the database connection as sqlite connections are not
|
||||
picklable.
|
||||
"""
|
||||
state = self.__dict__.copy()
|
||||
state["_db"] = None
|
||||
return state
|
||||
return {
|
||||
k: v for k, v in self.__dict__.items() if k not in {"_db", "db"}
|
||||
}
|
||||
|
||||
|
||||
# Database controller and supporting interfaces.
|
||||
|
|
@ -928,10 +967,10 @@ class Transaction:
|
|||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[Exception],
|
||||
exc_value: Exception,
|
||||
traceback: TracebackType,
|
||||
):
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> bool | None:
|
||||
"""Complete a transaction. This must be the most recently
|
||||
entered but not yet exited transaction. If it is the last active
|
||||
transaction, the database updates are committed.
|
||||
|
|
@ -947,6 +986,14 @@ class Transaction:
|
|||
self._mutated = False
|
||||
self.db._db_lock.release()
|
||||
|
||||
if (
|
||||
isinstance(exc_value, sqlite3.OperationalError)
|
||||
and exc_value.args[0] == "user-defined function raised exception"
|
||||
):
|
||||
raise DBCustomFunctionError()
|
||||
|
||||
return None
|
||||
|
||||
def query(
|
||||
self, statement: str, subvals: Sequence[SQLiteType] = ()
|
||||
) -> list[sqlite3.Row]:
|
||||
|
|
@ -1007,6 +1054,13 @@ class Database:
|
|||
"sqlite3 must be compiled with multi-threading support"
|
||||
)
|
||||
|
||||
# Print tracebacks for exceptions in user defined functions
|
||||
# See also `self.add_functions` and `DBCustomFunctionError`.
|
||||
#
|
||||
# `if`: use feature detection because PyPy doesn't support this.
|
||||
if hasattr(sqlite3, "enable_callback_tracebacks"):
|
||||
sqlite3.enable_callback_tracebacks(True)
|
||||
|
||||
self.path = path
|
||||
self.timeout = timeout
|
||||
|
||||
|
|
@ -1102,9 +1156,16 @@ class Database:
|
|||
|
||||
return bytestring
|
||||
|
||||
conn.create_function("regexp", 2, regexp)
|
||||
conn.create_function("unidecode", 1, unidecode)
|
||||
conn.create_function("bytelower", 1, bytelower)
|
||||
create_function = conn.create_function
|
||||
if sys.version_info >= (3, 8) and sqlite_version_info >= (3, 8, 3):
|
||||
# Let sqlite make extra optimizations
|
||||
create_function = functools.partial(
|
||||
conn.create_function, deterministic=True
|
||||
)
|
||||
|
||||
create_function("regexp", 2, regexp)
|
||||
create_function("unidecode", 1, unidecode)
|
||||
create_function("bytelower", 1, bytelower)
|
||||
|
||||
def _close(self):
|
||||
"""Close the all connections to the underlying SQLite database
|
||||
|
|
@ -1158,7 +1219,7 @@ class Database:
|
|||
"""
|
||||
# Get current schema.
|
||||
with self.transaction() as tx:
|
||||
rows = tx.query("PRAGMA table_info(%s)" % table)
|
||||
rows = tx.query(f"PRAGMA table_info({table})")
|
||||
current_fields = {row[1] for row in rows}
|
||||
|
||||
field_names = set(fields.keys())
|
||||
|
|
@ -1171,9 +1232,7 @@ class Database:
|
|||
columns = []
|
||||
for name, typ in fields.items():
|
||||
columns.append(f"{name} {typ.sql}")
|
||||
setup_sql = "CREATE TABLE {} ({});\n".format(
|
||||
table, ", ".join(columns)
|
||||
)
|
||||
setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n"
|
||||
|
||||
else:
|
||||
# Table exists does not match the field set.
|
||||
|
|
@ -1181,8 +1240,8 @@ class Database:
|
|||
for name, typ in fields.items():
|
||||
if name in current_fields:
|
||||
continue
|
||||
setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format(
|
||||
table, name, typ.sql
|
||||
setup_sql += (
|
||||
f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n"
|
||||
)
|
||||
|
||||
with self.transaction() as tx:
|
||||
|
|
@ -1193,18 +1252,16 @@ class Database:
|
|||
for the given entity (if they don't exist).
|
||||
"""
|
||||
with self.transaction() as tx:
|
||||
tx.script(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS {0} (
|
||||
tx.script(f"""
|
||||
CREATE TABLE IF NOT EXISTS {flex_table} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
entity_id INTEGER,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
UNIQUE(entity_id, key) ON CONFLICT REPLACE);
|
||||
CREATE INDEX IF NOT EXISTS {0}_by_entity
|
||||
ON {0} (entity_id);
|
||||
""".format(flex_table)
|
||||
)
|
||||
CREATE INDEX IF NOT EXISTS {flex_table}_by_entity
|
||||
ON {flex_table} (entity_id);
|
||||
""")
|
||||
|
||||
# Querying.
|
||||
|
||||
|
|
@ -1266,12 +1323,6 @@ class Database:
|
|||
sort if sort.is_slow() else None, # Slow sort component.
|
||||
)
|
||||
|
||||
def _get(
|
||||
self,
|
||||
model_cls: type[AnyModel],
|
||||
id,
|
||||
) -> AnyModel | None:
|
||||
"""Get a Model object by its id or None if the id does not
|
||||
exist.
|
||||
"""
|
||||
return self._fetch(model_cls, MatchQuery("id", id)).get()
|
||||
def _get(self, model_cls: type[AnyModel], id_: int) -> AnyModel | None:
|
||||
"""Get a Model object by its id or None if the id does not exist."""
|
||||
return self._fetch(model_cls, MatchQuery("id", id_)).get()
|
||||
|
|
|
|||
|
|
@ -16,26 +16,34 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator, MutableSequence, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from functools import cached_property, reduce
|
||||
from operator import mul, or_
|
||||
from re import Pattern
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
|
||||
|
||||
from beets import util
|
||||
from beets.util.units import raw_seconds_short
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.dbcore import Model
|
||||
from beets.dbcore.db import AnyModel
|
||||
from beets.dbcore.db import AnyModel, Model
|
||||
|
||||
P = TypeVar("P", default=Any)
|
||||
else:
|
||||
P = TypeVar("P")
|
||||
|
||||
# To use the SQLite "blob" type, it doesn't suffice to provide a byte
|
||||
# string; SQLite treats that as encoded text. Wrapping it in a
|
||||
# `memoryview` tells it that we actually mean non-text data.
|
||||
# needs to be defined in here due to circular import.
|
||||
# TODO: remove it from this module and define it in dbcore/types.py instead
|
||||
BLOB_TYPE = memoryview
|
||||
|
||||
|
||||
class ParsingError(ValueError):
|
||||
"""Abstract class for any unparsable user-requested album/query
|
||||
|
|
@ -78,6 +86,7 @@ class Query(ABC):
|
|||
"""Return a set with field names that this query operates on."""
|
||||
return set()
|
||||
|
||||
@abstractmethod
|
||||
def clause(self) -> tuple[str | None, Sequence[Any]]:
|
||||
"""Generate an SQLite expression implementing the query.
|
||||
|
||||
|
|
@ -88,14 +97,12 @@ class Query(ABC):
|
|||
The default implementation returns None, falling back to a slow query
|
||||
using `match()`.
|
||||
"""
|
||||
return None, ()
|
||||
|
||||
@abstractmethod
|
||||
def match(self, obj: Model):
|
||||
"""Check whether this query matches a given Model. Can be used to
|
||||
perform queries on arbitrary sets of Model.
|
||||
"""
|
||||
...
|
||||
|
||||
def __and__(self, other: Query) -> AndQuery:
|
||||
return AndQuery([self, other])
|
||||
|
|
@ -145,7 +152,7 @@ class FieldQuery(Query, Generic[P]):
|
|||
self.fast = fast
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
return self.field, ()
|
||||
raise NotImplementedError
|
||||
|
||||
def clause(self) -> tuple[str | None, Sequence[SQLiteType]]:
|
||||
if self.fast:
|
||||
|
|
@ -157,7 +164,7 @@ class FieldQuery(Query, Generic[P]):
|
|||
@classmethod
|
||||
def value_match(cls, pattern: P, value: Any):
|
||||
"""Determine whether the value matches the pattern."""
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedError
|
||||
|
||||
def match(self, obj: Model) -> bool:
|
||||
return self.value_match(self.pattern, obj.get(self.field_name))
|
||||
|
|
@ -183,7 +190,7 @@ class MatchQuery(FieldQuery[AnySQLiteType]):
|
|||
"""A query that looks for exact matches in an Model field."""
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
return self.field + " = ?", [self.pattern]
|
||||
return f"{self.field} = ?", [self.pattern]
|
||||
|
||||
@classmethod
|
||||
def value_match(cls, pattern: AnySQLiteType, value: Any) -> bool:
|
||||
|
|
@ -197,7 +204,7 @@ class NoneQuery(FieldQuery[None]):
|
|||
super().__init__(field, None, fast)
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
return self.field + " IS NULL", ()
|
||||
return f"{self.field} IS NULL", ()
|
||||
|
||||
def match(self, obj: Model) -> bool:
|
||||
return obj.get(self.field_name) is None
|
||||
|
|
@ -227,7 +234,7 @@ class StringFieldQuery(FieldQuery[P]):
|
|||
"""Determine whether the value matches the pattern. Both
|
||||
arguments are strings. Subclasses implement this method.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class StringQuery(StringFieldQuery[str]):
|
||||
|
|
@ -239,7 +246,7 @@ class StringQuery(StringFieldQuery[str]):
|
|||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clause = self.field + " like ? escape '\\'"
|
||||
clause = f"{self.field} like ? escape '\\'"
|
||||
subvals = [search]
|
||||
return clause, subvals
|
||||
|
||||
|
|
@ -257,8 +264,8 @@ class SubstringQuery(StringFieldQuery[str]):
|
|||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
search = "%" + pattern + "%"
|
||||
clause = self.field + " like ? escape '\\'"
|
||||
search = f"%{pattern}%"
|
||||
clause = f"{self.field} like ? escape '\\'"
|
||||
subvals = [search]
|
||||
return clause, subvals
|
||||
|
||||
|
|
@ -267,6 +274,91 @@ class SubstringQuery(StringFieldQuery[str]):
|
|||
return pattern.lower() in value.lower()
|
||||
|
||||
|
||||
class PathQuery(FieldQuery[bytes]):
|
||||
"""A query that matches all items under a given path.
|
||||
|
||||
Matching can either be case-insensitive or case-sensitive. By
|
||||
default, the behavior depends on the OS: case-insensitive on Windows
|
||||
and case-sensitive otherwise.
|
||||
"""
|
||||
|
||||
def __init__(self, field: str, pattern: bytes, fast: bool = True) -> None:
|
||||
"""Create a path query.
|
||||
|
||||
`pattern` must be a path, either to a file or a directory.
|
||||
"""
|
||||
path = util.normpath(pattern)
|
||||
|
||||
# Case sensitivity depends on the filesystem that the query path is located on.
|
||||
self.case_sensitive = util.case_sensitive(path)
|
||||
|
||||
# Use a normalized-case pattern for case-insensitive matches.
|
||||
if not self.case_sensitive:
|
||||
# We need to lowercase the entire path, not just the pattern.
|
||||
# In particular, on Windows, the drive letter is otherwise not
|
||||
# lowercased.
|
||||
# This also ensures that the `match()` method below and the SQL
|
||||
# from `col_clause()` do the same thing.
|
||||
path = path.lower()
|
||||
|
||||
super().__init__(field, path, fast)
|
||||
|
||||
@cached_property
|
||||
def dir_path(self) -> bytes:
|
||||
return os.path.join(self.pattern, b"")
|
||||
|
||||
@staticmethod
|
||||
def is_path_query(query_part: str) -> bool:
|
||||
"""Try to guess whether a unicode query part is a path query.
|
||||
|
||||
The path query must
|
||||
1. precede the colon in the query, if a colon is present
|
||||
2. contain either ``os.sep`` or ``os.altsep`` (Windows)
|
||||
3. this path must exist on the filesystem.
|
||||
"""
|
||||
query_part = query_part.split(":")[0]
|
||||
|
||||
return (
|
||||
# make sure the query part contains a path separator
|
||||
bool(set(query_part) & {os.sep, os.altsep})
|
||||
and os.path.exists(util.normpath(query_part))
|
||||
)
|
||||
|
||||
def match(self, obj: Model) -> bool:
|
||||
"""Check whether a model object's path matches this query.
|
||||
|
||||
Performs either an exact match against the pattern or checks if the path
|
||||
starts with the given directory path. Case sensitivity depends on the object's
|
||||
filesystem as determined during initialization.
|
||||
"""
|
||||
path = obj.path if self.case_sensitive else obj.path.lower()
|
||||
return (path == self.pattern) or path.startswith(self.dir_path)
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
"""Generate an SQL clause that implements path matching in the database.
|
||||
|
||||
Returns a tuple of SQL clause string and parameter values list that matches
|
||||
paths either exactly or by directory prefix. Handles case sensitivity
|
||||
appropriately using BYTELOWER for case-insensitive matches.
|
||||
"""
|
||||
if self.case_sensitive:
|
||||
left, right = self.field, "?"
|
||||
else:
|
||||
left, right = f"BYTELOWER({self.field})", "BYTELOWER(?)"
|
||||
|
||||
return f"({left} = {right}) || (substr({left}, 1, ?) = {right})", [
|
||||
BLOB_TYPE(self.pattern),
|
||||
len(dir_blob := BLOB_TYPE(self.dir_path)),
|
||||
dir_blob,
|
||||
]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
|
||||
f"fast={self.fast}, case_sensitive={self.case_sensitive})"
|
||||
)
|
||||
|
||||
|
||||
class RegexpQuery(StringFieldQuery[Pattern[str]]):
|
||||
"""A query that matches a regular expression in a specific Model field.
|
||||
|
||||
|
|
@ -320,39 +412,6 @@ class BooleanQuery(MatchQuery[int]):
|
|||
super().__init__(field_name, pattern_int, fast)
|
||||
|
||||
|
||||
class BytesQuery(FieldQuery[bytes]):
|
||||
"""Match a raw bytes field (i.e., a path). This is a necessary hack
|
||||
to work around the `sqlite3` module's desire to treat `bytes` and
|
||||
`unicode` equivalently in Python 2. Always use this query instead of
|
||||
`MatchQuery` when matching on BLOB values.
|
||||
"""
|
||||
|
||||
def __init__(self, field_name: str, pattern: bytes | str | memoryview):
|
||||
# Use a buffer/memoryview representation of the pattern for SQLite
|
||||
# matching. This instructs SQLite to treat the blob as binary
|
||||
# rather than encoded Unicode.
|
||||
if isinstance(pattern, (str, bytes)):
|
||||
if isinstance(pattern, str):
|
||||
bytes_pattern = pattern.encode("utf-8")
|
||||
else:
|
||||
bytes_pattern = pattern
|
||||
self.buf_pattern = memoryview(bytes_pattern)
|
||||
elif isinstance(pattern, memoryview):
|
||||
self.buf_pattern = pattern
|
||||
bytes_pattern = bytes(pattern)
|
||||
else:
|
||||
raise ValueError("pattern must be bytes, str, or memoryview")
|
||||
|
||||
super().__init__(field_name, bytes_pattern)
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
return self.field + " = ?", [self.buf_pattern]
|
||||
|
||||
@classmethod
|
||||
def value_match(cls, pattern: bytes, value: Any) -> bool:
|
||||
return pattern == value
|
||||
|
||||
|
||||
class NumericQuery(FieldQuery[str]):
|
||||
"""Matches numeric fields. A syntax using Ruby-style range ellipses
|
||||
(``..``) lets users specify one- or two-sided ranges. For example,
|
||||
|
|
@ -412,11 +471,11 @@ class NumericQuery(FieldQuery[str]):
|
|||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
if self.point is not None:
|
||||
return self.field + "=?", (self.point,)
|
||||
return f"{self.field}=?", (self.point,)
|
||||
else:
|
||||
if self.rangemin is not None and self.rangemax is not None:
|
||||
return (
|
||||
"{0} >= ? AND {0} <= ?".format(self.field),
|
||||
f"{self.field} >= ? AND {self.field} <= ?",
|
||||
(self.rangemin, self.rangemax),
|
||||
)
|
||||
elif self.rangemin is not None:
|
||||
|
|
@ -490,9 +549,9 @@ class CollectionQuery(Query):
|
|||
if not subq_clause:
|
||||
# Fall back to slow query.
|
||||
return None, ()
|
||||
clause_parts.append("(" + subq_clause + ")")
|
||||
clause_parts.append(f"({subq_clause})")
|
||||
subvals += subq_subvals
|
||||
clause = (" " + joiner + " ").join(clause_parts)
|
||||
clause = f" {joiner} ".join(clause_parts)
|
||||
return clause, subvals
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
|
@ -631,9 +690,7 @@ class Period:
|
|||
("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"), # second
|
||||
)
|
||||
relative_units = {"y": 365, "m": 30, "w": 7, "d": 1}
|
||||
relative_re = (
|
||||
"(?P<sign>[+|-]?)(?P<quantity>[0-9]+)" + "(?P<timespan>[y|m|w|d])"
|
||||
)
|
||||
relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[y|m|w|d])"
|
||||
|
||||
def __init__(self, date: datetime, precision: str):
|
||||
"""Create a period with the given date (a `datetime` object) and
|
||||
|
|
@ -741,9 +798,7 @@ class DateInterval:
|
|||
|
||||
def __init__(self, start: datetime | None, end: datetime | None):
|
||||
if start is not None and end is not None and not start < end:
|
||||
raise ValueError(
|
||||
"start date {} is not before end date {}".format(start, end)
|
||||
)
|
||||
raise ValueError(f"start date {start} is not before end date {end}")
|
||||
self.start = start
|
||||
self.end = end
|
||||
|
||||
|
|
@ -791,8 +846,6 @@ class DateQuery(FieldQuery[str]):
|
|||
date = datetime.fromtimestamp(timestamp)
|
||||
return self.interval.contains(date)
|
||||
|
||||
_clause_tmpl = "{0} {1} ?"
|
||||
|
||||
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
|
||||
clause_parts = []
|
||||
subvals = []
|
||||
|
|
@ -800,11 +853,11 @@ class DateQuery(FieldQuery[str]):
|
|||
# Convert the `datetime` objects to an integer number of seconds since
|
||||
# the (local) Unix epoch using `datetime.timestamp()`.
|
||||
if self.interval.start:
|
||||
clause_parts.append(self._clause_tmpl.format(self.field, ">="))
|
||||
clause_parts.append(f"{self.field} >= ?")
|
||||
subvals.append(int(self.interval.start.timestamp()))
|
||||
|
||||
if self.interval.end:
|
||||
clause_parts.append(self._clause_tmpl.format(self.field, "<"))
|
||||
clause_parts.append(f"{self.field} < ?")
|
||||
subvals.append(int(self.interval.end.timestamp()))
|
||||
|
||||
if clause_parts:
|
||||
|
|
@ -834,7 +887,7 @@ class DurationQuery(NumericQuery):
|
|||
if not s:
|
||||
return None
|
||||
try:
|
||||
return util.raw_seconds_short(s)
|
||||
return raw_seconds_short(s)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(s)
|
||||
|
|
@ -844,6 +897,24 @@ class DurationQuery(NumericQuery):
|
|||
)
|
||||
|
||||
|
||||
class SingletonQuery(FieldQuery[str]):
|
||||
"""This query is responsible for the 'singleton' lookup.
|
||||
|
||||
It is based on the FieldQuery and constructs a SQL clause
|
||||
'album_id is NULL' which yields the same result as the previous filter
|
||||
in Python but is more performant since it's done in SQL.
|
||||
|
||||
Using util.str2bool ensures that lookups like singleton:true, singleton:1
|
||||
and singleton:false, singleton:0 are handled consistently.
|
||||
"""
|
||||
|
||||
def __new__(cls, field: str, value: str, *args, **kwargs):
|
||||
query = NoneQuery("album_id")
|
||||
if util.str2bool(value):
|
||||
return query
|
||||
return NotQuery(query)
|
||||
|
||||
|
||||
# Sorting.
|
||||
|
||||
|
||||
|
|
@ -997,9 +1068,9 @@ class FixedFieldSort(FieldSort):
|
|||
if self.case_insensitive:
|
||||
field = (
|
||||
"(CASE "
|
||||
"WHEN TYPEOF({0})='text' THEN LOWER({0}) "
|
||||
"WHEN TYPEOF({0})='blob' THEN LOWER({0}) "
|
||||
"ELSE {0} END)".format(self.field)
|
||||
f"WHEN TYPEOF({self.field})='text' THEN LOWER({self.field}) "
|
||||
f"WHEN TYPEOF({self.field})='blob' THEN LOWER({self.field}) "
|
||||
f"ELSE {self.field} END)"
|
||||
)
|
||||
else:
|
||||
field = self.field
|
||||
|
|
|
|||
|
|
@ -16,19 +16,20 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
|
||||
|
||||
from beets.util import str2bool
|
||||
import beets
|
||||
from beets import util
|
||||
from beets.util.units import human_seconds_short, raw_seconds_short
|
||||
|
||||
from .query import (
|
||||
BooleanQuery,
|
||||
FieldQueryType,
|
||||
NumericQuery,
|
||||
SQLiteType,
|
||||
SubstringQuery,
|
||||
)
|
||||
from . import query
|
||||
|
||||
SQLiteType = query.SQLiteType
|
||||
BLOB_TYPE = query.BLOB_TYPE
|
||||
|
||||
|
||||
class ModelType(typing.Protocol):
|
||||
|
|
@ -61,7 +62,7 @@ class Type(ABC, Generic[T, N]):
|
|||
"""The SQLite column type for the value.
|
||||
"""
|
||||
|
||||
query: FieldQueryType = SubstringQuery
|
||||
query: query.FieldQueryType = query.SubstringQuery
|
||||
"""The `Query` subclass to be used when querying the field.
|
||||
"""
|
||||
|
||||
|
|
@ -160,7 +161,7 @@ class BaseInteger(Type[int, N]):
|
|||
"""A basic integer type."""
|
||||
|
||||
sql = "INTEGER"
|
||||
query = NumericQuery
|
||||
query = query.NumericQuery
|
||||
model_type = int
|
||||
|
||||
def normalize(self, value: Any) -> int | N:
|
||||
|
|
@ -193,7 +194,7 @@ class BasePaddedInt(BaseInteger[N]):
|
|||
self.digits = digits
|
||||
|
||||
def format(self, value: int | N) -> str:
|
||||
return "{0:0{1}d}".format(value or 0, self.digits)
|
||||
return f"{value or 0:0{self.digits}d}"
|
||||
|
||||
|
||||
class PaddedInt(BasePaddedInt[int]):
|
||||
|
|
@ -218,7 +219,7 @@ class ScaledInt(Integer):
|
|||
self.suffix = suffix
|
||||
|
||||
def format(self, value: int) -> str:
|
||||
return "{}{}".format((value or 0) // self.unit, self.suffix)
|
||||
return f"{(value or 0) // self.unit}{self.suffix}"
|
||||
|
||||
|
||||
class Id(NullInteger):
|
||||
|
|
@ -241,14 +242,14 @@ class BaseFloat(Type[float, N]):
|
|||
"""
|
||||
|
||||
sql = "REAL"
|
||||
query: FieldQueryType = NumericQuery
|
||||
query: query.FieldQueryType = query.NumericQuery
|
||||
model_type = float
|
||||
|
||||
def __init__(self, digits: int = 1):
|
||||
self.digits = digits
|
||||
|
||||
def format(self, value: float | N) -> str:
|
||||
return "{0:.{1}f}".format(value or 0, self.digits)
|
||||
return f"{value or 0:.{self.digits}f}"
|
||||
|
||||
|
||||
class Float(BaseFloat[float]):
|
||||
|
|
@ -271,7 +272,7 @@ class BaseString(Type[T, N]):
|
|||
"""A Unicode string type."""
|
||||
|
||||
sql = "TEXT"
|
||||
query = SubstringQuery
|
||||
query = query.SubstringQuery
|
||||
|
||||
def normalize(self, value: Any) -> T | N:
|
||||
if value is None:
|
||||
|
|
@ -291,7 +292,7 @@ class DelimitedString(BaseString[list[str], list[str]]):
|
|||
containing delimiter-separated values.
|
||||
"""
|
||||
|
||||
model_type = list
|
||||
model_type = list[str]
|
||||
|
||||
def __init__(self, delimiter: str):
|
||||
self.delimiter = delimiter
|
||||
|
|
@ -312,14 +313,145 @@ class Boolean(Type):
|
|||
"""A boolean type."""
|
||||
|
||||
sql = "INTEGER"
|
||||
query = BooleanQuery
|
||||
query = query.BooleanQuery
|
||||
model_type = bool
|
||||
|
||||
def format(self, value: bool) -> str:
|
||||
return str(bool(value))
|
||||
|
||||
def parse(self, string: str) -> bool:
|
||||
return str2bool(string)
|
||||
return util.str2bool(string)
|
||||
|
||||
|
||||
class DateType(Float):
|
||||
# TODO representation should be `datetime` object
|
||||
# TODO distinguish between date and time types
|
||||
query = query.DateQuery
|
||||
|
||||
def format(self, value):
|
||||
return time.strftime(
|
||||
beets.config["time_format"].as_str(), time.localtime(value or 0)
|
||||
)
|
||||
|
||||
def parse(self, string):
|
||||
try:
|
||||
# Try a formatted date string.
|
||||
return time.mktime(
|
||||
time.strptime(string, beets.config["time_format"].as_str())
|
||||
)
|
||||
except ValueError:
|
||||
# Fall back to a plain timestamp number.
|
||||
try:
|
||||
return float(string)
|
||||
except ValueError:
|
||||
return self.null
|
||||
|
||||
|
||||
class BasePathType(Type[bytes, N]):
|
||||
"""A dbcore type for filesystem paths.
|
||||
|
||||
These are represented as `bytes` objects, in keeping with
|
||||
the Unix filesystem abstraction.
|
||||
"""
|
||||
|
||||
sql = "BLOB"
|
||||
query = query.PathQuery
|
||||
model_type = bytes
|
||||
|
||||
def parse(self, string: str) -> bytes:
|
||||
return util.normpath(string)
|
||||
|
||||
def normalize(self, value: Any) -> bytes | N:
|
||||
if isinstance(value, str):
|
||||
# Paths stored internally as encoded bytes.
|
||||
return util.bytestring_path(value)
|
||||
|
||||
elif isinstance(value, BLOB_TYPE):
|
||||
# We unwrap buffers to bytes.
|
||||
return bytes(value)
|
||||
|
||||
else:
|
||||
return value
|
||||
|
||||
def from_sql(self, sql_value):
|
||||
return self.normalize(sql_value)
|
||||
|
||||
def to_sql(self, value: bytes) -> BLOB_TYPE:
|
||||
if isinstance(value, bytes):
|
||||
value = BLOB_TYPE(value)
|
||||
return value
|
||||
|
||||
|
||||
class NullPathType(BasePathType[None]):
|
||||
@property
|
||||
def null(self) -> None:
|
||||
return None
|
||||
|
||||
def format(self, value: bytes | None) -> str:
|
||||
return util.displayable_path(value or b"")
|
||||
|
||||
|
||||
class PathType(BasePathType[bytes]):
|
||||
@property
|
||||
def null(self) -> bytes:
|
||||
return b""
|
||||
|
||||
def format(self, value: bytes) -> str:
|
||||
return util.displayable_path(value or b"")
|
||||
|
||||
|
||||
class MusicalKey(String):
|
||||
"""String representing the musical key of a song.
|
||||
|
||||
The standard format is C, Cm, C#, C#m, etc.
|
||||
"""
|
||||
|
||||
ENHARMONIC = {
|
||||
r"db": "c#",
|
||||
r"eb": "d#",
|
||||
r"gb": "f#",
|
||||
r"ab": "g#",
|
||||
r"bb": "a#",
|
||||
}
|
||||
|
||||
null = None
|
||||
|
||||
def parse(self, key):
|
||||
key = key.lower()
|
||||
for flat, sharp in self.ENHARMONIC.items():
|
||||
key = re.sub(flat, sharp, key)
|
||||
key = re.sub(r"[\W\s]+minor", "m", key)
|
||||
key = re.sub(r"[\W\s]+major", "", key)
|
||||
return key.capitalize()
|
||||
|
||||
def normalize(self, key):
|
||||
if key is None:
|
||||
return None
|
||||
else:
|
||||
return self.parse(key)
|
||||
|
||||
|
||||
class DurationType(Float):
|
||||
"""Human-friendly (M:SS) representation of a time interval."""
|
||||
|
||||
query = query.DurationQuery
|
||||
|
||||
def format(self, value):
|
||||
if not beets.config["format_raw_length"].get(bool):
|
||||
return human_seconds_short(value or 0.0)
|
||||
else:
|
||||
return value
|
||||
|
||||
def parse(self, string):
|
||||
try:
|
||||
# Try to format back hh:ss to seconds.
|
||||
return raw_seconds_short(string)
|
||||
except ValueError:
|
||||
# Fall back to a plain float.
|
||||
try:
|
||||
return float(string)
|
||||
except ValueError:
|
||||
return self.null
|
||||
|
||||
|
||||
# Shared instances of common types.
|
||||
|
|
@ -331,6 +463,7 @@ FLOAT = Float()
|
|||
NULL_FLOAT = NullFloat()
|
||||
STRING = String()
|
||||
BOOLEAN = Boolean()
|
||||
DATE = DateType()
|
||||
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
|
||||
|
||||
# Will set the proper null char in mediafile
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# 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
|
||||
|
|
@ -11,17 +12,27 @@
|
|||
# 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."""
|
||||
"""Provides the basic, interface-agnostic workflow for importing and
|
||||
autotagging music files.
|
||||
"""
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from .session import ImportAbortError, ImportSession
|
||||
from .tasks import (
|
||||
Action,
|
||||
ArchiveImportTask,
|
||||
ImportTask,
|
||||
SentinelImportTask,
|
||||
SingletonImportTask,
|
||||
)
|
||||
|
||||
# Note: Stages are not exposed to the public API
|
||||
|
||||
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."
|
||||
)
|
||||
__all__ = [
|
||||
"ImportSession",
|
||||
"ImportAbortError",
|
||||
"Action",
|
||||
"ImportTask",
|
||||
"ArchiveImportTask",
|
||||
"SentinelImportTask",
|
||||
"SingletonImportTask",
|
||||
]
|
||||
308
beets/importer/session.py
Normal file
308
beets/importer/session.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import config, dbcore, library, logging, plugins, util
|
||||
from beets.importer.tasks import Action
|
||||
from beets.util import displayable_path, normpath, pipeline, syspath
|
||||
|
||||
from . import stages as stagefuncs
|
||||
from .state import ImportState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from beets.util import PathBytes
|
||||
|
||||
from .tasks import ImportTask
|
||||
|
||||
|
||||
QUEUE_SIZE = 128
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
class ImportAbortError(Exception):
|
||||
"""Raised when the user aborts the tagging operation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ImportSession:
|
||||
"""Controls an import action. Subclasses should implement methods to
|
||||
communicate with the user or otherwise make decisions.
|
||||
"""
|
||||
|
||||
logger: logging.Logger
|
||||
paths: list[PathBytes]
|
||||
lib: library.Library
|
||||
|
||||
_is_resuming: dict[bytes, bool]
|
||||
_merged_items: set[PathBytes]
|
||||
_merged_dirs: set[PathBytes]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lib: library.Library,
|
||||
loghandler: logging.Handler | None,
|
||||
paths: Sequence[PathBytes] | None,
|
||||
query: dbcore.Query | None,
|
||||
):
|
||||
"""Create a session.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lib : library.Library
|
||||
The library instance to which items will be imported.
|
||||
loghandler : logging.Handler or None
|
||||
A logging handler to use for the session's logger. If None, a
|
||||
NullHandler will be used.
|
||||
paths : os.PathLike or None
|
||||
The paths to be imported.
|
||||
query : dbcore.Query or None
|
||||
A query to filter items for import.
|
||||
"""
|
||||
self.lib = lib
|
||||
self.logger = self._setup_logging(loghandler)
|
||||
self.query = query
|
||||
self._is_resuming = {}
|
||||
self._merged_items = set()
|
||||
self._merged_dirs = set()
|
||||
|
||||
# Normalize the paths.
|
||||
self.paths = list(map(normpath, paths or []))
|
||||
|
||||
def _setup_logging(self, loghandler: logging.Handler | None):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.propagate = False
|
||||
if not loghandler:
|
||||
loghandler = logging.NullHandler()
|
||||
logger.handlers = [loghandler]
|
||||
return logger
|
||||
|
||||
def set_config(self, config):
|
||||
"""Set `config` property from global import config and make
|
||||
implied changes.
|
||||
"""
|
||||
# FIXME: Maybe this function should not exist and should instead
|
||||
# provide "decision wrappers" like "should_resume()", etc.
|
||||
iconfig = dict(config)
|
||||
self.config = iconfig
|
||||
|
||||
# Incremental and progress are mutually exclusive.
|
||||
if iconfig["incremental"]:
|
||||
iconfig["resume"] = False
|
||||
|
||||
# When based on a query instead of directories, never
|
||||
# save progress or try to resume.
|
||||
if self.query is not None:
|
||||
iconfig["resume"] = False
|
||||
iconfig["incremental"] = False
|
||||
|
||||
if iconfig["reflink"]:
|
||||
iconfig["reflink"] = iconfig["reflink"].as_choice(
|
||||
["auto", True, False]
|
||||
)
|
||||
|
||||
# Copy, move, reflink, link, and hardlink are mutually exclusive.
|
||||
if iconfig["move"]:
|
||||
iconfig["copy"] = False
|
||||
iconfig["link"] = False
|
||||
iconfig["hardlink"] = False
|
||||
iconfig["reflink"] = False
|
||||
elif iconfig["link"]:
|
||||
iconfig["copy"] = False
|
||||
iconfig["move"] = False
|
||||
iconfig["hardlink"] = False
|
||||
iconfig["reflink"] = False
|
||||
elif iconfig["hardlink"]:
|
||||
iconfig["copy"] = False
|
||||
iconfig["move"] = False
|
||||
iconfig["link"] = False
|
||||
iconfig["reflink"] = False
|
||||
elif iconfig["reflink"]:
|
||||
iconfig["copy"] = False
|
||||
iconfig["move"] = False
|
||||
iconfig["link"] = False
|
||||
iconfig["hardlink"] = False
|
||||
|
||||
# Only delete when copying.
|
||||
if not iconfig["copy"]:
|
||||
iconfig["delete"] = False
|
||||
|
||||
self.want_resume = config["resume"].as_choice([True, False, "ask"])
|
||||
|
||||
def tag_log(self, status, paths: Sequence[PathBytes]):
|
||||
"""Log a message about a given album to the importer log. The status
|
||||
should reflect the reason the album couldn't be tagged.
|
||||
"""
|
||||
self.logger.info("{} {}", status, displayable_path(paths))
|
||||
|
||||
def log_choice(self, task: ImportTask, duplicate=False):
|
||||
"""Logs the task's current choice if it should be logged. If
|
||||
``duplicate``, then this is a secondary choice after a duplicate was
|
||||
detected and a decision was made.
|
||||
"""
|
||||
paths = task.paths
|
||||
if duplicate:
|
||||
# Duplicate: log all three choices (skip, keep both, and trump).
|
||||
if task.should_remove_duplicates:
|
||||
self.tag_log("duplicate-replace", paths)
|
||||
elif task.choice_flag in (Action.ASIS, Action.APPLY):
|
||||
self.tag_log("duplicate-keep", paths)
|
||||
elif task.choice_flag is Action.SKIP:
|
||||
self.tag_log("duplicate-skip", paths)
|
||||
else:
|
||||
# Non-duplicate: log "skip" and "asis" choices.
|
||||
if task.choice_flag is Action.ASIS:
|
||||
self.tag_log("asis", paths)
|
||||
elif task.choice_flag is Action.SKIP:
|
||||
self.tag_log("skip", paths)
|
||||
|
||||
def should_resume(self, path: PathBytes):
|
||||
raise NotImplementedError
|
||||
|
||||
def choose_match(self, task: ImportTask):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_duplicate(self, task: ImportTask, found_duplicates):
|
||||
raise NotImplementedError
|
||||
|
||||
def choose_item(self, task: ImportTask):
|
||||
raise NotImplementedError
|
||||
|
||||
def run(self):
|
||||
"""Run the import task."""
|
||||
self.logger.info("import started {}", time.asctime())
|
||||
self.set_config(config["import"])
|
||||
|
||||
# Set up the pipeline.
|
||||
if self.query is None:
|
||||
stages = [stagefuncs.read_tasks(self)]
|
||||
else:
|
||||
stages = [stagefuncs.query_tasks(self)]
|
||||
|
||||
# In pretend mode, just log what would otherwise be imported.
|
||||
if self.config["pretend"]:
|
||||
stages += [stagefuncs.log_files(self)]
|
||||
else:
|
||||
if self.config["group_albums"] and not self.config["singletons"]:
|
||||
# Split directory tasks into one task for each album.
|
||||
stages += [stagefuncs.group_albums(self)]
|
||||
|
||||
# These stages either talk to the user to get a decision or,
|
||||
# in the case of a non-autotagged import, just choose to
|
||||
# import everything as-is. In *both* cases, these stages
|
||||
# also add the music to the library database, so later
|
||||
# stages need to read and write data from there.
|
||||
if self.config["autotag"]:
|
||||
stages += [
|
||||
stagefuncs.lookup_candidates(self),
|
||||
stagefuncs.user_query(self),
|
||||
]
|
||||
else:
|
||||
stages += [stagefuncs.import_asis(self)]
|
||||
|
||||
# Plugin stages.
|
||||
for stage_func in plugins.early_import_stages():
|
||||
stages.append(stagefuncs.plugin_stage(self, stage_func))
|
||||
for stage_func in plugins.import_stages():
|
||||
stages.append(stagefuncs.plugin_stage(self, stage_func))
|
||||
|
||||
stages += [stagefuncs.manipulate_files(self)]
|
||||
|
||||
pl = pipeline.Pipeline(stages)
|
||||
|
||||
# Run the pipeline.
|
||||
plugins.send("import_begin", session=self)
|
||||
try:
|
||||
if config["threaded"]:
|
||||
pl.run_parallel(QUEUE_SIZE)
|
||||
else:
|
||||
pl.run_sequential()
|
||||
except ImportAbortError:
|
||||
# User aborted operation. Silently stop.
|
||||
pass
|
||||
|
||||
# Incremental and resumed imports
|
||||
|
||||
def already_imported(self, toppath: PathBytes, paths: Sequence[PathBytes]):
|
||||
"""Returns true if the files belonging to this task have already
|
||||
been imported in a previous session.
|
||||
"""
|
||||
if self.is_resuming(toppath) and all(
|
||||
[ImportState().progress_has_element(toppath, p) for p in paths]
|
||||
):
|
||||
return True
|
||||
if self.config["incremental"] and tuple(paths) in self.history_dirs:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
_history_dirs = None
|
||||
|
||||
@property
|
||||
def history_dirs(self) -> set[tuple[PathBytes, ...]]:
|
||||
# FIXME: This could be simplified to a cached property
|
||||
if self._history_dirs is None:
|
||||
self._history_dirs = ImportState().taghistory
|
||||
return self._history_dirs
|
||||
|
||||
def already_merged(self, paths: Sequence[PathBytes]):
|
||||
"""Returns true if all the paths being imported were part of a merge
|
||||
during previous tasks.
|
||||
"""
|
||||
for path in paths:
|
||||
if path not in self._merged_items and path not in self._merged_dirs:
|
||||
return False
|
||||
return True
|
||||
|
||||
def mark_merged(self, paths: Sequence[PathBytes]):
|
||||
"""Mark paths and directories as merged for future reimport tasks."""
|
||||
self._merged_items.update(paths)
|
||||
dirs = {
|
||||
os.path.dirname(path) if os.path.isfile(syspath(path)) else path
|
||||
for path in paths
|
||||
}
|
||||
self._merged_dirs.update(dirs)
|
||||
|
||||
def is_resuming(self, toppath: PathBytes):
|
||||
"""Return `True` if user wants to resume import of this path.
|
||||
|
||||
You have to call `ask_resume` first to determine the return value.
|
||||
"""
|
||||
return self._is_resuming.get(toppath, False)
|
||||
|
||||
def ask_resume(self, toppath: PathBytes):
|
||||
"""If import of `toppath` was aborted in an earlier session, ask
|
||||
user if they want to resume the import.
|
||||
|
||||
Determines the return value of `is_resuming(toppath)`.
|
||||
"""
|
||||
if self.want_resume and ImportState().progress_has(toppath):
|
||||
# Either accept immediately or prompt for input to decide.
|
||||
if self.want_resume is True or self.should_resume(toppath):
|
||||
log.warning(
|
||||
"Resuming interrupted import of {}",
|
||||
util.displayable_path(toppath),
|
||||
)
|
||||
self._is_resuming[toppath] = True
|
||||
else:
|
||||
# Clear progress; we're starting from the top.
|
||||
ImportState().progress_reset(toppath)
|
||||
392
beets/importer/stages.py
Normal file
392
beets/importer/stages.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import config, plugins
|
||||
from beets.util import MoveOperation, displayable_path, pipeline
|
||||
|
||||
from .tasks import (
|
||||
Action,
|
||||
ImportTask,
|
||||
ImportTaskFactory,
|
||||
SentinelImportTask,
|
||||
SingletonImportTask,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from beets import library
|
||||
|
||||
from .session import ImportSession
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
# ---------------------------- Producer functions ---------------------------- #
|
||||
# Functions that are called first i.e. they generate import tasks
|
||||
|
||||
|
||||
def read_tasks(session: ImportSession):
|
||||
"""A generator yielding all the albums (as ImportTask objects) found
|
||||
in the user-specified list of paths. In the case of a singleton
|
||||
import, yields single-item tasks instead.
|
||||
"""
|
||||
skipped = 0
|
||||
|
||||
for toppath in session.paths:
|
||||
# Check whether we need to resume the import.
|
||||
session.ask_resume(toppath)
|
||||
|
||||
# Generate tasks.
|
||||
task_factory = ImportTaskFactory(toppath, session)
|
||||
yield from task_factory.tasks()
|
||||
skipped += task_factory.skipped
|
||||
|
||||
if not task_factory.imported:
|
||||
log.warning("No files imported from {}", displayable_path(toppath))
|
||||
|
||||
# Show skipped directories (due to incremental/resume).
|
||||
if skipped:
|
||||
log.info("Skipped {} paths.", skipped)
|
||||
|
||||
|
||||
def query_tasks(session: ImportSession):
|
||||
"""A generator that works as a drop-in-replacement for read_tasks.
|
||||
Instead of finding files from the filesystem, a query is used to
|
||||
match items from the library.
|
||||
"""
|
||||
task: ImportTask
|
||||
if session.config["singletons"]:
|
||||
# Search for items.
|
||||
for item in session.lib.items(session.query):
|
||||
task = SingletonImportTask(None, item)
|
||||
for task in task.handle_created(session):
|
||||
yield task
|
||||
|
||||
else:
|
||||
# Search for albums.
|
||||
for album in session.lib.albums(session.query):
|
||||
log.debug(
|
||||
"yielding album {0.id}: {0.albumartist} - {0.album}", album
|
||||
)
|
||||
items = list(album.items())
|
||||
_freshen_items(items)
|
||||
|
||||
task = ImportTask(None, [album.item_dir()], items)
|
||||
for task in task.handle_created(session):
|
||||
yield task
|
||||
|
||||
|
||||
# ---------------------------------- Stages ---------------------------------- #
|
||||
# Functions that process import tasks, may transform or filter them
|
||||
# They are chained together in the pipeline e.g. stage2(stage1(task)) -> task
|
||||
|
||||
|
||||
def group_albums(session: ImportSession):
|
||||
"""A pipeline stage that groups the items of each task into albums
|
||||
using their metadata.
|
||||
|
||||
Groups are identified using their artist and album fields. The
|
||||
pipeline stage emits new album tasks for each discovered group.
|
||||
"""
|
||||
|
||||
def group(item):
|
||||
return (item.albumartist or item.artist, item.album)
|
||||
|
||||
task = None
|
||||
while True:
|
||||
task = yield task
|
||||
if task.skip:
|
||||
continue
|
||||
tasks = []
|
||||
sorted_items: list[library.Item] = sorted(task.items, key=group)
|
||||
for _, items in itertools.groupby(sorted_items, group):
|
||||
l_items = list(items)
|
||||
task = ImportTask(task.toppath, [i.path for i in l_items], l_items)
|
||||
tasks += task.handle_created(session)
|
||||
tasks.append(SentinelImportTask(task.toppath, task.paths))
|
||||
|
||||
task = pipeline.multiple(tasks)
|
||||
|
||||
|
||||
@pipeline.mutator_stage
|
||||
def lookup_candidates(session: ImportSession, task: ImportTask):
|
||||
"""A coroutine for performing the initial MusicBrainz lookup for an
|
||||
album. It accepts lists of Items and yields
|
||||
(items, cur_artist, cur_album, candidates, rec) tuples. If no match
|
||||
is found, all of the yielded parameters (except items) are None.
|
||||
"""
|
||||
if task.skip:
|
||||
# FIXME This gets duplicated a lot. We need a better
|
||||
# abstraction.
|
||||
return
|
||||
|
||||
plugins.send("import_task_start", session=session, task=task)
|
||||
log.debug("Looking up: {}", displayable_path(task.paths))
|
||||
|
||||
# Restrict the initial lookup to IDs specified by the user via the -m
|
||||
# option. Currently all the IDs are passed onto the tasks directly.
|
||||
task.lookup_candidates(session.config["search_ids"].as_str_seq())
|
||||
|
||||
|
||||
@pipeline.stage
|
||||
def user_query(session: ImportSession, task: ImportTask):
|
||||
"""A coroutine for interfacing with the user about the tagging
|
||||
process.
|
||||
|
||||
The coroutine accepts an ImportTask objects. It uses the
|
||||
session's `choose_match` method to determine the `action` for
|
||||
this task. Depending on the action additional stages are executed
|
||||
and the processed task is yielded.
|
||||
|
||||
It emits the ``import_task_choice`` event for plugins. Plugins have
|
||||
access to the choice via the ``task.choice_flag`` property and may
|
||||
choose to change it.
|
||||
"""
|
||||
if task.skip:
|
||||
return task
|
||||
|
||||
if session.already_merged(task.paths):
|
||||
return pipeline.BUBBLE
|
||||
|
||||
# Ask the user for a choice.
|
||||
task.choose_match(session)
|
||||
plugins.send("import_task_choice", session=session, task=task)
|
||||
|
||||
# As-tracks: transition to singleton workflow.
|
||||
if task.choice_flag is Action.TRACKS:
|
||||
# Set up a little pipeline for dealing with the singletons.
|
||||
def emitter(task):
|
||||
for item in task.items:
|
||||
task = SingletonImportTask(task.toppath, item)
|
||||
yield from task.handle_created(session)
|
||||
yield SentinelImportTask(task.toppath, task.paths)
|
||||
|
||||
return _extend_pipeline(
|
||||
emitter(task), lookup_candidates(session), user_query(session)
|
||||
)
|
||||
|
||||
# As albums: group items by albums and create task for each album
|
||||
if task.choice_flag is Action.ALBUMS:
|
||||
return _extend_pipeline(
|
||||
[task],
|
||||
group_albums(session),
|
||||
lookup_candidates(session),
|
||||
user_query(session),
|
||||
)
|
||||
|
||||
_resolve_duplicates(session, task)
|
||||
|
||||
if task.should_merge_duplicates:
|
||||
# Create a new task for tagging the current items
|
||||
# and duplicates together
|
||||
duplicate_items = task.duplicate_items(session.lib)
|
||||
|
||||
# Duplicates would be reimported so make them look "fresh"
|
||||
_freshen_items(duplicate_items)
|
||||
duplicate_paths = [item.path for item in duplicate_items]
|
||||
|
||||
# Record merged paths in the session so they are not reimported
|
||||
session.mark_merged(duplicate_paths)
|
||||
|
||||
merged_task = ImportTask(
|
||||
None, task.paths + duplicate_paths, task.items + duplicate_items
|
||||
)
|
||||
|
||||
return _extend_pipeline(
|
||||
[merged_task], lookup_candidates(session), user_query(session)
|
||||
)
|
||||
|
||||
_apply_choice(session, task)
|
||||
return task
|
||||
|
||||
|
||||
@pipeline.mutator_stage
|
||||
def import_asis(session: ImportSession, task: ImportTask):
|
||||
"""Select the `action.ASIS` choice for all tasks.
|
||||
|
||||
This stage replaces the initial_lookup and user_query stages
|
||||
when the importer is run without autotagging.
|
||||
"""
|
||||
if task.skip:
|
||||
return
|
||||
|
||||
log.info("{}", displayable_path(task.paths))
|
||||
task.set_choice(Action.ASIS)
|
||||
_apply_choice(session, task)
|
||||
|
||||
|
||||
@pipeline.mutator_stage
|
||||
def plugin_stage(
|
||||
session: ImportSession,
|
||||
func: Callable[[ImportSession, ImportTask], None],
|
||||
task: ImportTask,
|
||||
):
|
||||
"""A coroutine (pipeline stage) that calls the given function with
|
||||
each non-skipped import task. These stages occur between applying
|
||||
metadata changes and moving/copying/writing files.
|
||||
"""
|
||||
if task.skip:
|
||||
return
|
||||
|
||||
func(session, task)
|
||||
|
||||
# Stage may modify DB, so re-load cached item data.
|
||||
# FIXME Importer plugins should not modify the database but instead
|
||||
# the albums and items attached to tasks.
|
||||
task.reload()
|
||||
|
||||
|
||||
@pipeline.stage
|
||||
def log_files(session: ImportSession, task: ImportTask):
|
||||
"""A coroutine (pipeline stage) to log each file to be imported."""
|
||||
if isinstance(task, SingletonImportTask):
|
||||
log.info("Singleton: {}", displayable_path(task.item["path"]))
|
||||
elif task.items:
|
||||
log.info("Album: {}", displayable_path(task.paths[0]))
|
||||
for item in task.items:
|
||||
log.info(" {}", displayable_path(item["path"]))
|
||||
|
||||
|
||||
# --------------------------------- Consumer --------------------------------- #
|
||||
# Anything that should be placed last in the pipeline
|
||||
# In theory every stage could be a consumer, but in practice there are some
|
||||
# functions which are typically placed last in the pipeline
|
||||
|
||||
|
||||
@pipeline.stage
|
||||
def manipulate_files(session: ImportSession, task: ImportTask):
|
||||
"""A coroutine (pipeline stage) that performs necessary file
|
||||
manipulations *after* items have been added to the library and
|
||||
finalizes each task.
|
||||
"""
|
||||
if not task.skip:
|
||||
if task.should_remove_duplicates:
|
||||
task.remove_duplicates(session.lib)
|
||||
|
||||
if session.config["move"]:
|
||||
operation = MoveOperation.MOVE
|
||||
elif session.config["copy"]:
|
||||
operation = MoveOperation.COPY
|
||||
elif session.config["link"]:
|
||||
operation = MoveOperation.LINK
|
||||
elif session.config["hardlink"]:
|
||||
operation = MoveOperation.HARDLINK
|
||||
elif session.config["reflink"] == "auto":
|
||||
operation = MoveOperation.REFLINK_AUTO
|
||||
elif session.config["reflink"]:
|
||||
operation = MoveOperation.REFLINK
|
||||
else:
|
||||
operation = None
|
||||
|
||||
task.manipulate_files(
|
||||
session=session,
|
||||
operation=operation,
|
||||
write=session.config["write"],
|
||||
)
|
||||
|
||||
# Progress, cleanup, and event.
|
||||
task.finalize(session)
|
||||
|
||||
|
||||
# ---------------------------- Utility functions ----------------------------- #
|
||||
# Private functions only used in the stages above
|
||||
|
||||
|
||||
def _apply_choice(session: ImportSession, task: ImportTask):
|
||||
"""Apply the task's choice to the Album or Item it contains and add
|
||||
it to the library.
|
||||
"""
|
||||
if task.skip:
|
||||
return
|
||||
|
||||
# Change metadata.
|
||||
if task.apply:
|
||||
task.apply_metadata()
|
||||
plugins.send("import_task_apply", session=session, task=task)
|
||||
|
||||
task.add(session.lib)
|
||||
|
||||
# If ``set_fields`` is set, set those fields to the
|
||||
# configured values.
|
||||
# NOTE: This cannot be done before the ``task.add()`` call above,
|
||||
# because then the ``ImportTask`` won't have an `album` for which
|
||||
# it can set the fields.
|
||||
if config["import"]["set_fields"]:
|
||||
task.set_fields(session.lib)
|
||||
|
||||
|
||||
def _resolve_duplicates(session: ImportSession, task: ImportTask):
|
||||
"""Check if a task conflicts with items or albums already imported
|
||||
and ask the session to resolve this.
|
||||
"""
|
||||
if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG):
|
||||
found_duplicates = task.find_duplicates(session.lib)
|
||||
if found_duplicates:
|
||||
log.debug("found duplicates: {}", [o.id for o in found_duplicates])
|
||||
|
||||
# Get the default action to follow from config.
|
||||
duplicate_action = config["import"]["duplicate_action"].as_choice(
|
||||
{
|
||||
"skip": "s",
|
||||
"keep": "k",
|
||||
"remove": "r",
|
||||
"merge": "m",
|
||||
"ask": "a",
|
||||
}
|
||||
)
|
||||
log.debug("default action for duplicates: {}", duplicate_action)
|
||||
|
||||
if duplicate_action == "s":
|
||||
# Skip new.
|
||||
task.set_choice(Action.SKIP)
|
||||
elif duplicate_action == "k":
|
||||
# Keep both. Do nothing; leave the choice intact.
|
||||
pass
|
||||
elif duplicate_action == "r":
|
||||
# Remove old.
|
||||
task.should_remove_duplicates = True
|
||||
elif duplicate_action == "m":
|
||||
# Merge duplicates together
|
||||
task.should_merge_duplicates = True
|
||||
else:
|
||||
# No default action set; ask the session.
|
||||
session.resolve_duplicate(task, found_duplicates)
|
||||
|
||||
session.log_choice(task, True)
|
||||
|
||||
|
||||
def _freshen_items(items):
|
||||
# Clear IDs from re-tagged items so they appear "fresh" when
|
||||
# we add them back to the library.
|
||||
for item in items:
|
||||
item.id = None
|
||||
item.album_id = None
|
||||
|
||||
|
||||
def _extend_pipeline(tasks, *stages):
|
||||
# Return pipeline extension for stages with list of tasks
|
||||
if isinstance(tasks, list):
|
||||
task_iter = iter(tasks)
|
||||
else:
|
||||
task_iter = tasks
|
||||
|
||||
ipl = pipeline.Pipeline([task_iter] + list(stages))
|
||||
return pipeline.multiple(ipl.pull())
|
||||
142
beets/importer/state.py
Normal file
142
beets/importer/state.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from bisect import bisect_left, insort
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beets import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.util import PathBytes
|
||||
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportState:
|
||||
"""Representing the progress of an import task.
|
||||
|
||||
Opens the state file on creation of the class. If you want
|
||||
to ensure the state is written to disk, you should use the
|
||||
context manager protocol.
|
||||
|
||||
Tagprogress allows long tagging tasks to be resumed when they pause.
|
||||
|
||||
Taghistory is a utility for manipulating the "incremental" import log.
|
||||
This keeps track of all directories that were ever imported, which
|
||||
allows the importer to only import new stuff.
|
||||
|
||||
Usage
|
||||
-----
|
||||
```
|
||||
# Readonly
|
||||
progress = ImportState().tagprogress
|
||||
|
||||
# Read and write
|
||||
with ImportState() as state:
|
||||
state["key"] = "value"
|
||||
```
|
||||
"""
|
||||
|
||||
tagprogress: dict[PathBytes, list[PathBytes]]
|
||||
taghistory: set[tuple[PathBytes, ...]]
|
||||
path: PathBytes
|
||||
|
||||
def __init__(self, readonly=False, path: PathBytes | None = None):
|
||||
self.path = path or os.fsencode(config["statefile"].as_filename())
|
||||
self.tagprogress = {}
|
||||
self.taghistory = set()
|
||||
self._open()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._save()
|
||||
|
||||
def _open(
|
||||
self,
|
||||
):
|
||||
try:
|
||||
with open(self.path, "rb") as f:
|
||||
state = pickle.load(f)
|
||||
# Read the states
|
||||
self.tagprogress = state.get("tagprogress", {})
|
||||
self.taghistory = state.get("taghistory", set())
|
||||
except Exception as exc:
|
||||
# The `pickle` module can emit all sorts of exceptions during
|
||||
# unpickling, including ImportError. We use a catch-all
|
||||
# exception to avoid enumerating them all (the docs don't even have a
|
||||
# full list!).
|
||||
log.debug("state file could not be read: {}", exc)
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
with open(self.path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"tagprogress": self.tagprogress,
|
||||
"taghistory": self.taghistory,
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.error("state file could not be written: {}", exc)
|
||||
|
||||
# -------------------------------- Tagprogress ------------------------------- #
|
||||
|
||||
def progress_add(self, toppath: PathBytes, *paths: PathBytes):
|
||||
"""Record that the files under all of the `paths` have been imported
|
||||
under `toppath`.
|
||||
"""
|
||||
with self as state:
|
||||
imported = state.tagprogress.setdefault(toppath, [])
|
||||
for path in paths:
|
||||
if imported and imported[-1] <= path:
|
||||
imported.append(path)
|
||||
else:
|
||||
insort(imported, path)
|
||||
|
||||
def progress_has_element(self, toppath: PathBytes, path: PathBytes) -> bool:
|
||||
"""Return whether `path` has been imported in `toppath`."""
|
||||
imported = self.tagprogress.get(toppath, [])
|
||||
i = bisect_left(imported, path)
|
||||
return i != len(imported) and imported[i] == path
|
||||
|
||||
def progress_has(self, toppath: PathBytes) -> bool:
|
||||
"""Return `True` if there exist paths that have already been
|
||||
imported under `toppath`.
|
||||
"""
|
||||
return toppath in self.tagprogress
|
||||
|
||||
def progress_reset(self, toppath: PathBytes | None):
|
||||
"""Reset the progress for `toppath`."""
|
||||
with self as state:
|
||||
if toppath in state.tagprogress:
|
||||
del state.tagprogress[toppath]
|
||||
|
||||
# -------------------------------- Taghistory -------------------------------- #
|
||||
|
||||
def history_add(self, paths: list[PathBytes]):
|
||||
"""Add the paths to the history."""
|
||||
with self as state:
|
||||
state.taghistory.add(tuple(paths))
|
||||
File diff suppressed because it is too large
Load diff
29
beets/library/__init__.py
Normal file
29
beets/library/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from beets.util.deprecation import deprecate_imports
|
||||
|
||||
from .exceptions import FileOperationError, ReadError, WriteError
|
||||
from .library import Library
|
||||
from .models import Album, Item, LibModel
|
||||
from .queries import parse_query_parts, parse_query_string
|
||||
|
||||
NEW_MODULE_BY_NAME = dict.fromkeys(
|
||||
("DateType", "DurationType", "MusicalKey", "PathType"), "beets.dbcore.types"
|
||||
) | dict.fromkeys(
|
||||
("BLOB_TYPE", "SingletonQuery", "PathQuery"), "beets.dbcore.query"
|
||||
)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
return deprecate_imports(__name__, NEW_MODULE_BY_NAME, name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Library",
|
||||
"LibModel",
|
||||
"Album",
|
||||
"Item",
|
||||
"parse_query_parts",
|
||||
"parse_query_string",
|
||||
"FileOperationError",
|
||||
"ReadError",
|
||||
"WriteError",
|
||||
]
|
||||
38
beets/library/exceptions.py
Normal file
38
beets/library/exceptions.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from beets import util
|
||||
|
||||
|
||||
class FileOperationError(Exception):
|
||||
"""Indicate an error when interacting with a file on disk.
|
||||
|
||||
Possibilities include an unsupported media type, a permissions
|
||||
error, and an unhandled Mutagen exception.
|
||||
"""
|
||||
|
||||
def __init__(self, path, reason):
|
||||
"""Create an exception describing an operation on the file at
|
||||
`path` with the underlying (chained) exception `reason`.
|
||||
"""
|
||||
super().__init__(path, reason)
|
||||
self.path = path
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
"""Get a string representing the error.
|
||||
|
||||
Describe both the underlying reason and the file path in question.
|
||||
"""
|
||||
return f"{util.displayable_path(self.path)}: {self.reason}"
|
||||
|
||||
|
||||
class ReadError(FileOperationError):
|
||||
"""An error while reading a file (i.e. in `Item.read`)."""
|
||||
|
||||
def __str__(self):
|
||||
return f"error reading {super()}"
|
||||
|
||||
|
||||
class WriteError(FileOperationError):
|
||||
"""An error while writing a file (i.e. in `Item.write`)."""
|
||||
|
||||
def __str__(self):
|
||||
return f"error writing {super()}"
|
||||
144
beets/library/library.py
Normal file
144
beets/library/library.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import platformdirs
|
||||
|
||||
import beets
|
||||
from beets import dbcore
|
||||
from beets.util import normpath
|
||||
|
||||
from .models import Album, Item
|
||||
from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.dbcore import Results
|
||||
|
||||
|
||||
class Library(dbcore.Database):
|
||||
"""A database of music containing songs and albums."""
|
||||
|
||||
_models = (Item, Album)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path="library.blb",
|
||||
directory: str | None = None,
|
||||
path_formats=((PF_KEY_DEFAULT, "$artist/$album/$track $title"),),
|
||||
replacements=None,
|
||||
):
|
||||
timeout = beets.config["timeout"].as_number()
|
||||
super().__init__(path, timeout=timeout)
|
||||
|
||||
self.directory = normpath(directory or platformdirs.user_music_path())
|
||||
|
||||
self.path_formats = path_formats
|
||||
self.replacements = replacements
|
||||
|
||||
# Used for template substitution performance.
|
||||
self._memotable: dict[tuple[str, ...], str] = {}
|
||||
|
||||
# Adding objects to the database.
|
||||
|
||||
def add(self, obj):
|
||||
"""Add the :class:`Item` or :class:`Album` object to the library
|
||||
database.
|
||||
|
||||
Return the object's new id.
|
||||
"""
|
||||
obj.add(self)
|
||||
self._memotable = {}
|
||||
return obj.id
|
||||
|
||||
def add_album(self, items):
|
||||
"""Create a new album consisting of a list of items.
|
||||
|
||||
The items are added to the database if they don't yet have an
|
||||
ID. Return a new :class:`Album` object. The list items must not
|
||||
be empty.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("need at least one item")
|
||||
|
||||
# Create the album structure using metadata from the first item.
|
||||
values = {key: items[0][key] for key in Album.item_keys}
|
||||
album = Album(self, **values)
|
||||
|
||||
# Add the album structure and set the items' album_id fields.
|
||||
# Store or add the items.
|
||||
with self.transaction():
|
||||
album.add(self)
|
||||
for item in items:
|
||||
item.album_id = album.id
|
||||
if item.id is None:
|
||||
item.add(self)
|
||||
else:
|
||||
item.store()
|
||||
|
||||
return album
|
||||
|
||||
# Querying.
|
||||
|
||||
def _fetch(self, model_cls, query, sort=None):
|
||||
"""Parse a query and fetch.
|
||||
|
||||
If an order specification is present in the query string
|
||||
the `sort` argument is ignored.
|
||||
"""
|
||||
# Parse the query, if necessary.
|
||||
try:
|
||||
parsed_sort = None
|
||||
if isinstance(query, str):
|
||||
query, parsed_sort = parse_query_string(query, model_cls)
|
||||
elif isinstance(query, (list, tuple)):
|
||||
query, parsed_sort = parse_query_parts(query, model_cls)
|
||||
except dbcore.query.InvalidQueryArgumentValueError as exc:
|
||||
raise dbcore.InvalidQueryError(query, exc)
|
||||
|
||||
# Any non-null sort specified by the parsed query overrides the
|
||||
# provided sort.
|
||||
if parsed_sort and not isinstance(parsed_sort, dbcore.query.NullSort):
|
||||
sort = parsed_sort
|
||||
|
||||
return super()._fetch(model_cls, query, sort)
|
||||
|
||||
@staticmethod
|
||||
def get_default_album_sort():
|
||||
"""Get a :class:`Sort` object for albums from the config option."""
|
||||
return dbcore.sort_from_strings(
|
||||
Album, beets.config["sort_album"].as_str_seq()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_default_item_sort():
|
||||
"""Get a :class:`Sort` object for items from the config option."""
|
||||
return dbcore.sort_from_strings(
|
||||
Item, beets.config["sort_item"].as_str_seq()
|
||||
)
|
||||
|
||||
def albums(self, query=None, sort=None) -> Results[Album]:
|
||||
"""Get :class:`Album` objects matching the query."""
|
||||
return self._fetch(Album, query, sort or self.get_default_album_sort())
|
||||
|
||||
def items(self, query=None, sort=None) -> Results[Item]:
|
||||
"""Get :class:`Item` objects matching the query."""
|
||||
return self._fetch(Item, query, sort or self.get_default_item_sort())
|
||||
|
||||
# Convenience accessors.
|
||||
def get_item(self, id_: int) -> Item | None:
|
||||
"""Fetch a :class:`Item` by its ID.
|
||||
|
||||
Return `None` if no match is found.
|
||||
"""
|
||||
return self._get(Item, id_)
|
||||
|
||||
def get_album(self, item_or_id: Item | int) -> Album | None:
|
||||
"""Given an album ID or an item associated with an album, return
|
||||
a :class:`Album` object for the album.
|
||||
|
||||
If no such album exists, return `None`.
|
||||
"""
|
||||
album_id = (
|
||||
item_or_id if isinstance(item_or_id, int) else item_or_id.album_id
|
||||
)
|
||||
return self._get(Album, album_id) if album_id else None
|
||||
File diff suppressed because it is too large
Load diff
61
beets/library/queries.py
Normal file
61
beets/library/queries.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
|
||||
import beets
|
||||
from beets import dbcore, logging, plugins
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
# Special path format key.
|
||||
PF_KEY_DEFAULT = "default"
|
||||
|
||||
# Query construction helpers.
|
||||
|
||||
|
||||
def parse_query_parts(parts, model_cls):
|
||||
"""Given a beets query string as a list of components, return the
|
||||
`Query` and `Sort` they represent.
|
||||
|
||||
Like `dbcore.parse_sorted_query`, with beets query prefixes and
|
||||
ensuring that implicit path queries are made explicit with 'path::<query>'
|
||||
"""
|
||||
# Get query types and their prefix characters.
|
||||
prefixes = {
|
||||
":": dbcore.query.RegexpQuery,
|
||||
"=~": dbcore.query.StringQuery,
|
||||
"=": dbcore.query.MatchQuery,
|
||||
}
|
||||
prefixes.update(plugins.queries())
|
||||
|
||||
# Special-case path-like queries, which are non-field queries
|
||||
# containing path separators (/).
|
||||
parts = [
|
||||
f"path:{s}" if dbcore.query.PathQuery.is_path_query(s) else s
|
||||
for s in parts
|
||||
]
|
||||
|
||||
case_insensitive = beets.config["sort_case_insensitive"].get(bool)
|
||||
|
||||
query, sort = dbcore.parse_sorted_query(
|
||||
model_cls, parts, prefixes, case_insensitive
|
||||
)
|
||||
log.debug("Parsed query: {!r}", query)
|
||||
log.debug("Parsed sort: {!r}", sort)
|
||||
return query, sort
|
||||
|
||||
|
||||
def parse_query_string(s, model_cls):
|
||||
"""Given a beets query string, return the `Query` and `Sort` they
|
||||
represent.
|
||||
|
||||
The string is split into components using shell-like syntax.
|
||||
"""
|
||||
message = f"Query is not unicode: {s!r}"
|
||||
assert isinstance(s, str), message
|
||||
try:
|
||||
parts = shlex.split(s)
|
||||
except ValueError as exc:
|
||||
raise dbcore.InvalidQueryError(s, exc)
|
||||
return parse_query_parts(parts, model_cls)
|
||||
|
|
@ -20,6 +20,9 @@ use {}-style formatting and can interpolate keywords arguments to the logging
|
|||
calls (`debug`, `info`, etc).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from copy import copy
|
||||
from logging import (
|
||||
|
|
@ -32,8 +35,10 @@ from logging import (
|
|||
Handler,
|
||||
Logger,
|
||||
NullHandler,
|
||||
RootLogger,
|
||||
StreamHandler,
|
||||
)
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
|
||||
|
||||
__all__ = [
|
||||
"DEBUG",
|
||||
|
|
@ -49,8 +54,31 @@ __all__ = [
|
|||
"getLogger",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
def logsafe(val):
|
||||
T = TypeVar("T")
|
||||
from types import TracebackType
|
||||
|
||||
# see https://github.com/python/typeshed/blob/main/stdlib/logging/__init__.pyi
|
||||
_SysExcInfoType = Union[
|
||||
tuple[type[BaseException], BaseException, Union[TracebackType, None]],
|
||||
tuple[None, None, None],
|
||||
]
|
||||
_ExcInfoType = Union[None, bool, _SysExcInfoType, BaseException]
|
||||
_ArgsType = Union[tuple[object, ...], Mapping[str, object]]
|
||||
|
||||
|
||||
# Regular expression to match:
|
||||
# - C0 control characters (0x00-0x1F) except useful whitespace (\t, \n, \r)
|
||||
# - DEL control character (0x7f)
|
||||
# - C1 control characters (0x80-0x9F)
|
||||
# Used to sanitize log messages that could disrupt terminal output
|
||||
_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]")
|
||||
_UNICODE_REPLACEMENT_CHARACTER = "\ufffd"
|
||||
|
||||
|
||||
def _logsafe(val: T) -> str | T:
|
||||
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
|
||||
|
||||
This is particularly relevant for bytestring paths. Much of our code
|
||||
|
|
@ -64,6 +92,10 @@ def logsafe(val):
|
|||
# type, and (b) warn the developer if they do this for other
|
||||
# bytestrings.
|
||||
return val.decode("utf-8", "replace")
|
||||
if isinstance(val, str):
|
||||
# Sanitize log messages by replacing control characters that can disrupt
|
||||
# terminals.
|
||||
return _CONTROL_CHAR_REGEX.sub(_UNICODE_REPLACEMENT_CHARACTER, val)
|
||||
|
||||
# Other objects are used as-is so field access, etc., still works in
|
||||
# the format string. Relies on a working __str__ implementation.
|
||||
|
|
@ -83,40 +115,45 @@ class StrFormatLogger(Logger):
|
|||
"""
|
||||
|
||||
class _LogMessage:
|
||||
def __init__(self, msg, args, kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
msg: str,
|
||||
args: _ArgsType,
|
||||
kwargs: dict[str, Any],
|
||||
):
|
||||
self.msg = msg
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __str__(self):
|
||||
args = [logsafe(a) for a in self.args]
|
||||
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
|
||||
args = [_logsafe(a) for a in self.args]
|
||||
kwargs = {k: _logsafe(v) for (k, v) in self.kwargs.items()}
|
||||
return self.msg.format(*args, **kwargs)
|
||||
|
||||
def _log(
|
||||
self,
|
||||
level,
|
||||
msg,
|
||||
args,
|
||||
exc_info=None,
|
||||
extra=None,
|
||||
stack_info=False,
|
||||
level: int,
|
||||
msg: object,
|
||||
args: _ArgsType,
|
||||
exc_info: _ExcInfoType = None,
|
||||
extra: Mapping[str, Any] | None = None,
|
||||
stack_info: bool = False,
|
||||
stacklevel: int = 1,
|
||||
**kwargs,
|
||||
):
|
||||
"""Log msg.format(*args, **kwargs)"""
|
||||
m = self._LogMessage(msg, args, kwargs)
|
||||
|
||||
stacklevel = kwargs.pop("stacklevel", 1)
|
||||
stacklevel = {"stacklevel": stacklevel}
|
||||
if isinstance(msg, str):
|
||||
msg = self._LogMessage(msg, args, kwargs)
|
||||
|
||||
return super()._log(
|
||||
level,
|
||||
m,
|
||||
msg,
|
||||
(),
|
||||
exc_info=exc_info,
|
||||
extra=extra,
|
||||
stack_info=stack_info,
|
||||
**stacklevel,
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -156,9 +193,12 @@ my_manager = copy(Logger.manager)
|
|||
my_manager.loggerClass = BeetsLogger
|
||||
|
||||
|
||||
# Override the `getLogger` to use our machinery.
|
||||
def getLogger(name=None): # noqa
|
||||
@overload
|
||||
def getLogger(name: str) -> BeetsLogger: ...
|
||||
@overload
|
||||
def getLogger(name: None = ...) -> RootLogger: ...
|
||||
def getLogger(name=None) -> BeetsLogger | RootLogger: # noqa: N802
|
||||
if name:
|
||||
return my_manager.getLogger(name)
|
||||
return my_manager.getLogger(name) # type: ignore[return-value]
|
||||
else:
|
||||
return Logger.root
|
||||
|
|
|
|||
|
|
@ -13,17 +13,11 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
import warnings
|
||||
|
||||
import mediafile
|
||||
|
||||
warnings.warn(
|
||||
"beets.mediafile is deprecated; use mediafile instead",
|
||||
# Show the location of the `import mediafile` statement as the warning's
|
||||
# source, rather than this file, such that the offending module can be
|
||||
# identified easily.
|
||||
stacklevel=2,
|
||||
)
|
||||
from .util.deprecation import deprecate_for_maintainers
|
||||
|
||||
deprecate_for_maintainers("'beets.mediafile'", "'mediafile'", stacklevel=2)
|
||||
|
||||
# Import everything from the mediafile module into this module.
|
||||
for key, value in mediafile.__dict__.items():
|
||||
|
|
@ -31,4 +25,4 @@ for key, value in mediafile.__dict__.items():
|
|||
globals()[key] = value
|
||||
|
||||
# Cleanup namespace.
|
||||
del key, value, warnings, mediafile
|
||||
del key, value, mediafile
|
||||
|
|
|
|||
359
beets/metadata_plugins.py
Normal file
359
beets/metadata_plugins.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""Metadata source plugin interface.
|
||||
|
||||
This allows beets to lookup metadata from various sources. We define
|
||||
a common interface for all metadata sources which need to be
|
||||
implemented as plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import re
|
||||
from functools import cache, cached_property
|
||||
from typing import TYPE_CHECKING, Generic, Literal, TypedDict, TypeVar
|
||||
|
||||
import unidecode
|
||||
from confuse import NotFoundError
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from beets.util import cached_classproperty
|
||||
from beets.util.id_extractors import extract_release_id
|
||||
|
||||
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from .autotag.hooks import AlbumInfo, Item, TrackInfo
|
||||
|
||||
|
||||
@cache
|
||||
def find_metadata_source_plugins() -> list[MetadataSourcePlugin]:
|
||||
"""Return a list of all loaded metadata source plugins."""
|
||||
# TODO: Make this an isinstance(MetadataSourcePlugin, ...) check in v3.0.0
|
||||
return [p for p in find_plugins() if hasattr(p, "data_source")] # type: ignore[misc]
|
||||
|
||||
|
||||
@notify_info_yielded("albuminfo_received")
|
||||
def candidates(*args, **kwargs) -> Iterable[AlbumInfo]:
|
||||
"""Return matching album candidates from all metadata source plugins."""
|
||||
for plugin in find_metadata_source_plugins():
|
||||
yield from plugin.candidates(*args, **kwargs)
|
||||
|
||||
|
||||
@notify_info_yielded("trackinfo_received")
|
||||
def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]:
|
||||
"""Return matching track candidates fromm all metadata source plugins."""
|
||||
for plugin in find_metadata_source_plugins():
|
||||
yield from plugin.item_candidates(*args, **kwargs)
|
||||
|
||||
|
||||
def album_for_id(_id: str) -> AlbumInfo | None:
|
||||
"""Get AlbumInfo object for the given ID string.
|
||||
|
||||
A single ID can yield just a single album, so we return the first match.
|
||||
"""
|
||||
for plugin in find_metadata_source_plugins():
|
||||
if info := plugin.album_for_id(album_id=_id):
|
||||
send("albuminfo_received", info=info)
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def track_for_id(_id: str) -> TrackInfo | None:
|
||||
"""Get TrackInfo object for the given ID string.
|
||||
|
||||
A single ID can yield just a single track, so we return the first match.
|
||||
"""
|
||||
for plugin in find_metadata_source_plugins():
|
||||
if info := plugin.track_for_id(_id):
|
||||
send("trackinfo_received", info=info)
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@cache
|
||||
def get_penalty(data_source: str | None) -> float:
|
||||
"""Get the penalty value for the given data source."""
|
||||
return next(
|
||||
(
|
||||
p.data_source_mismatch_penalty
|
||||
for p in find_metadata_source_plugins()
|
||||
if p.data_source == data_source
|
||||
),
|
||||
MetadataSourcePlugin.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY,
|
||||
)
|
||||
|
||||
|
||||
class MetadataSourcePlugin(BeetsPlugin, metaclass=abc.ABCMeta):
|
||||
"""A plugin that provides metadata from a specific source.
|
||||
|
||||
This base class implements a contract for plugins that provide metadata
|
||||
from a specific source. The plugin must implement the methods to search for albums
|
||||
and tracks, and to retrieve album and track information by ID.
|
||||
"""
|
||||
|
||||
DEFAULT_DATA_SOURCE_MISMATCH_PENALTY = 0.5
|
||||
|
||||
@cached_classproperty
|
||||
def data_source(cls) -> str:
|
||||
"""The data source name for this plugin.
|
||||
|
||||
This is inferred from the plugin name.
|
||||
"""
|
||||
return cls.__name__.replace("Plugin", "") # type: ignore[attr-defined]
|
||||
|
||||
@cached_property
|
||||
def data_source_mismatch_penalty(self) -> float:
|
||||
try:
|
||||
return self.config["source_weight"].as_number()
|
||||
except NotFoundError:
|
||||
return self.config["data_source_mismatch_penalty"].as_number()
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.config.add(
|
||||
{
|
||||
"search_limit": 5,
|
||||
"data_source_mismatch_penalty": self.DEFAULT_DATA_SOURCE_MISMATCH_PENALTY, # noqa: E501
|
||||
}
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""Return :py:class:`AlbumInfo` object or None if no matching release was
|
||||
found."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
||||
"""Return a :py:class:`TrackInfo` object or None if no matching release was
|
||||
found.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---------------------------------- search ---------------------------------- #
|
||||
|
||||
@abc.abstractmethod
|
||||
def candidates(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
artist: str,
|
||||
album: str,
|
||||
va_likely: bool,
|
||||
) -> Iterable[AlbumInfo]:
|
||||
"""Return :py:class:`AlbumInfo` candidates that match the given album.
|
||||
|
||||
Used in the autotag functionality to search for albums.
|
||||
|
||||
:param items: List of items in the album
|
||||
:param artist: Album artist
|
||||
:param album: Album name
|
||||
:param va_likely: Whether the album is likely to be by various artists
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
"""Return :py:class:`TrackInfo` candidates that match the given track.
|
||||
|
||||
Used in the autotag functionality to search for tracks.
|
||||
|
||||
:param item: Track item
|
||||
:param artist: Track artist
|
||||
:param title: Track title
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def albums_for_ids(self, ids: Sequence[str]) -> Iterable[AlbumInfo | None]:
|
||||
"""Batch lookup of album metadata for a list of album IDs.
|
||||
|
||||
Given a list of album identifiers, yields corresponding AlbumInfo objects.
|
||||
Missing albums result in None values in the output iterator.
|
||||
Plugins may implement this for optimized batched lookups instead of
|
||||
single calls to album_for_id.
|
||||
"""
|
||||
|
||||
return (self.album_for_id(id) for id in ids)
|
||||
|
||||
def tracks_for_ids(self, ids: Sequence[str]) -> Iterable[TrackInfo | None]:
|
||||
"""Batch lookup of track metadata for a list of track IDs.
|
||||
|
||||
Given a list of track identifiers, yields corresponding TrackInfo objects.
|
||||
Missing tracks result in None values in the output iterator.
|
||||
Plugins may implement this for optimized batched lookups instead of
|
||||
single calls to track_for_id.
|
||||
"""
|
||||
|
||||
return (self.track_for_id(id) for id in ids)
|
||||
|
||||
def _extract_id(self, url: str) -> str | None:
|
||||
"""Extract an ID from a URL for this metadata source plugin.
|
||||
|
||||
Uses the plugin's data source name to determine the ID format and
|
||||
extracts the ID from a given URL.
|
||||
"""
|
||||
return extract_release_id(self.data_source, url)
|
||||
|
||||
@staticmethod
|
||||
def get_artist(
|
||||
artists: Iterable[dict[str | int, str]],
|
||||
id_key: str | int = "id",
|
||||
name_key: str | int = "name",
|
||||
join_key: str | int | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Returns an artist string (all artists) and an artist_id (the main
|
||||
artist) for a list of artist object dicts.
|
||||
|
||||
For each artist, this function moves articles (such as 'a', 'an', and 'the')
|
||||
to the front. It returns a tuple containing the comma-separated string
|
||||
of all normalized artists and the ``id`` of the main/first artist.
|
||||
Alternatively a keyword can be used to combine artists together into a
|
||||
single string by passing the join_key argument.
|
||||
|
||||
:param artists: Iterable of artist dicts or lists returned by API.
|
||||
:param id_key: Key or index corresponding to the value of ``id`` for
|
||||
the main/first artist. Defaults to 'id'.
|
||||
:param name_key: Key or index corresponding to values of names
|
||||
to concatenate for the artist string (containing all artists).
|
||||
Defaults to 'name'.
|
||||
:param join_key: Key or index corresponding to a field containing a
|
||||
keyword to use for combining artists into a single string, for
|
||||
example "Feat.", "Vs.", "And" or similar. The default is None
|
||||
which keeps the default behaviour (comma-separated).
|
||||
:return: Normalized artist string.
|
||||
"""
|
||||
artist_id = None
|
||||
artist_string = ""
|
||||
artists = list(artists) # In case a generator was passed.
|
||||
total = len(artists)
|
||||
for idx, artist in enumerate(artists):
|
||||
if not artist_id:
|
||||
artist_id = artist[id_key]
|
||||
name = artist[name_key]
|
||||
# Move articles to the front.
|
||||
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
|
||||
# Use a join keyword if requested and available.
|
||||
if idx < (total - 1): # Skip joining on last.
|
||||
if join_key and artist.get(join_key, None):
|
||||
name += f" {artist[join_key]} "
|
||||
else:
|
||||
name += ", "
|
||||
artist_string += name
|
||||
|
||||
return artist_string, artist_id
|
||||
|
||||
|
||||
class IDResponse(TypedDict):
|
||||
"""Response from the API containing an ID."""
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class SearchFilter(TypedDict):
|
||||
artist: NotRequired[str]
|
||||
album: NotRequired[str]
|
||||
|
||||
|
||||
R = TypeVar("R", bound=IDResponse)
|
||||
|
||||
|
||||
class SearchApiMetadataSourcePlugin(
|
||||
Generic[R], MetadataSourcePlugin, metaclass=abc.ABCMeta
|
||||
):
|
||||
"""Helper class to implement a metadata source plugin with an API.
|
||||
|
||||
Plugins using this ABC must implement an API search method to
|
||||
retrieve album and track information by ID,
|
||||
i.e. `album_for_id` and `track_for_id`, and a search method to
|
||||
perform a search on the API. The search method should return a list
|
||||
of identifiers for the requested type (album or track).
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.config.add(
|
||||
{
|
||||
"search_query_ascii": False,
|
||||
}
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal["album", "track"],
|
||||
filters: SearchFilter,
|
||||
query_string: str = "",
|
||||
) -> Sequence[R]:
|
||||
"""Perform a search on the API.
|
||||
|
||||
:param query_type: The type of query to perform.
|
||||
:param filters: A dictionary of filters to apply to the search.
|
||||
:param query_string: Additional query to include in the search.
|
||||
|
||||
Should return a list of identifiers for the requested type (album or track).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def candidates(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
artist: str,
|
||||
album: str,
|
||||
va_likely: bool,
|
||||
) -> Iterable[AlbumInfo]:
|
||||
query_filters: SearchFilter = {}
|
||||
if album:
|
||||
query_filters["album"] = album
|
||||
if not va_likely:
|
||||
query_filters["artist"] = artist
|
||||
|
||||
results = self._search_api("album", query_filters)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
return filter(
|
||||
None, self.albums_for_ids([result["id"] for result in results])
|
||||
)
|
||||
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
results = self._search_api(
|
||||
"track", {"artist": artist}, query_string=title
|
||||
)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
return filter(
|
||||
None,
|
||||
self.tracks_for_ids([result["id"] for result in results if result]),
|
||||
)
|
||||
|
||||
def _construct_search_query(
|
||||
self, filters: SearchFilter, query_string: str
|
||||
) -> str:
|
||||
"""Construct a query string with the specified filters and keywords to
|
||||
be provided to the spotify (or similar) search API.
|
||||
|
||||
The returned format was initially designed for spotify's search API but
|
||||
we found is also useful with other APIs that support similar query structures.
|
||||
see `spotify <https://developer.spotify.com/documentation/web-api/reference/search>`_
|
||||
and `deezer <https://developers.deezer.com/api/search>`_.
|
||||
|
||||
:param filters: Field filters to apply.
|
||||
:param query_string: Query keywords to use.
|
||||
:return: Query string to be provided to the search API.
|
||||
"""
|
||||
|
||||
components = [query_string, *(f"{k}:'{v}'" for k, v in filters.items())]
|
||||
query = " ".join(filter(None, components))
|
||||
|
||||
if self.config["search_query_ascii"].get():
|
||||
query = unidecode.unidecode(query)
|
||||
|
||||
return query
|
||||
881
beets/plugins.py
881
beets/plugins.py
File diff suppressed because it is too large
Load diff
0
beets/py.typed
Normal file
0
beets/py.typed
Normal file
|
|
@ -63,8 +63,8 @@ HAVE_SYMLINK = sys.platform != "win32"
|
|||
HAVE_HARDLINK = sys.platform != "win32"
|
||||
|
||||
|
||||
def item(lib=None):
|
||||
i = beets.library.Item(
|
||||
def item(lib=None, **kwargs):
|
||||
defaults = dict(
|
||||
title="the title",
|
||||
artist="the artist",
|
||||
albumartist="the album artist",
|
||||
|
|
@ -99,6 +99,7 @@ def item(lib=None):
|
|||
album_id=None,
|
||||
mtime=12345,
|
||||
)
|
||||
i = beets.library.Item(**{**defaults, **kwargs})
|
||||
if lib:
|
||||
lib.add(i)
|
||||
return i
|
||||
|
|
@ -106,38 +107,14 @@ def item(lib=None):
|
|||
|
||||
# Dummy import session.
|
||||
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
|
||||
cls = commands.TerminalImportSession if cli else importer.ImportSession
|
||||
cls = (
|
||||
commands.import_.session.TerminalImportSession
|
||||
if cli
|
||||
else importer.ImportSession
|
||||
)
|
||||
return cls(lib, loghandler, paths, query)
|
||||
|
||||
|
||||
class Assertions:
|
||||
"""A mixin with additional unit test assertions."""
|
||||
|
||||
def assertExists(self, path):
|
||||
assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
|
||||
|
||||
def assertNotExists(self, path):
|
||||
assert not os.path.exists(syspath(path)), f"file exists: {path!r}"
|
||||
|
||||
def assertIsFile(self, path):
|
||||
self.assertExists(path)
|
||||
assert os.path.isfile(
|
||||
syspath(path)
|
||||
), "path exists, but is not a regular file: {!r}".format(path)
|
||||
|
||||
def assertIsDir(self, path):
|
||||
self.assertExists(path)
|
||||
assert os.path.isdir(
|
||||
syspath(path)
|
||||
), "path exists, but is not a directory: {!r}".format(path)
|
||||
|
||||
def assert_equal_path(self, a, b):
|
||||
"""Check that two paths are equal."""
|
||||
a_bytes, b_bytes = util.normpath(a), util.normpath(b)
|
||||
|
||||
assert a_bytes == b_bytes, f"{a_bytes=} != {b_bytes=}"
|
||||
|
||||
|
||||
# Mock I/O.
|
||||
|
||||
|
||||
|
|
@ -180,7 +157,7 @@ class DummyIn:
|
|||
self.out = out
|
||||
|
||||
def add(self, s):
|
||||
self.buf.append(s + "\n")
|
||||
self.buf.append(f"{s}\n")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import subprocess
|
|||
import sys
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from io import StringIO
|
||||
|
|
@ -48,12 +49,12 @@ from mediafile import Image, MediaFile
|
|||
|
||||
import beets
|
||||
import beets.plugins
|
||||
from beets import autotag, importer, logging, util
|
||||
from beets import importer, logging, util
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.importer import ImportSession
|
||||
from beets.library import Album, Item, Library
|
||||
from beets.library import Item, Library
|
||||
from beets.test import _common
|
||||
from beets.ui.commands import TerminalImportSession
|
||||
from beets.ui.commands.import_.session import TerminalImportSession
|
||||
from beets.util import (
|
||||
MoveOperation,
|
||||
bytestring_path,
|
||||
|
|
@ -162,15 +163,49 @@ NEEDS_REFLINK = unittest.skipUnless(
|
|||
)
|
||||
|
||||
|
||||
class TestHelper(_common.Assertions, ConfigMixin):
|
||||
class IOMixin:
|
||||
@cached_property
|
||||
def io(self) -> _common.DummyIO:
|
||||
return _common.DummyIO()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.io.install()
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.io.restore()
|
||||
|
||||
|
||||
class TestHelper(ConfigMixin):
|
||||
"""Helper mixin for high-level cli and plugin tests.
|
||||
|
||||
This mixin provides methods to isolate beets' global state provide
|
||||
fixtures.
|
||||
"""
|
||||
|
||||
resource_path = Path(os.fsdecode(_common.RSRC)) / "full.mp3"
|
||||
|
||||
db_on_disk: ClassVar[bool] = False
|
||||
|
||||
@cached_property
|
||||
def temp_dir_path(self) -> Path:
|
||||
return Path(self.create_temp_dir())
|
||||
|
||||
@cached_property
|
||||
def temp_dir(self) -> bytes:
|
||||
return util.bytestring_path(self.temp_dir_path)
|
||||
|
||||
@cached_property
|
||||
def lib_path(self) -> Path:
|
||||
lib_path = self.temp_dir_path / "libdir"
|
||||
lib_path.mkdir(exist_ok=True)
|
||||
return lib_path
|
||||
|
||||
@cached_property
|
||||
def libdir(self) -> bytes:
|
||||
return bytestring_path(self.lib_path)
|
||||
|
||||
# TODO automate teardown through hook registration
|
||||
|
||||
def setup_beets(self):
|
||||
|
|
@ -193,8 +228,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
|
||||
Make sure you call ``teardown_beets()`` afterwards.
|
||||
"""
|
||||
self.create_temp_dir()
|
||||
temp_dir_str = os.fsdecode(self.temp_dir)
|
||||
temp_dir_str = str(self.temp_dir_path)
|
||||
self.env_patcher = patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
|
|
@ -204,9 +238,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
)
|
||||
self.env_patcher.start()
|
||||
|
||||
self.libdir = os.path.join(self.temp_dir, b"libdir")
|
||||
os.mkdir(syspath(self.libdir))
|
||||
self.config["directory"] = os.fsdecode(self.libdir)
|
||||
self.config["directory"] = str(self.lib_path)
|
||||
|
||||
if self.db_on_disk:
|
||||
dbpath = util.bytestring_path(self.config["library"].as_filename())
|
||||
|
|
@ -214,12 +246,8 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
dbpath = ":memory:"
|
||||
self.lib = Library(dbpath, self.libdir)
|
||||
|
||||
# Initialize, but don't install, a DummyIO.
|
||||
self.io = _common.DummyIO()
|
||||
|
||||
def teardown_beets(self):
|
||||
self.env_patcher.stop()
|
||||
self.io.restore()
|
||||
self.lib._close()
|
||||
self.remove_temp_dir()
|
||||
|
||||
|
|
@ -238,7 +266,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
The item is attached to the database from `self.lib`.
|
||||
"""
|
||||
values_ = {
|
||||
"title": "t\u00eftle {0}",
|
||||
"title": "t\u00eftle {}",
|
||||
"artist": "the \u00e4rtist",
|
||||
"album": "the \u00e4lbum",
|
||||
"track": 1,
|
||||
|
|
@ -249,7 +277,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
values_["db"] = self.lib
|
||||
item = Item(**values_)
|
||||
if "path" not in values:
|
||||
item["path"] = "audio." + item["format"].lower()
|
||||
item["path"] = f"audio.{item['format'].lower()}"
|
||||
# mtime needs to be set last since other assignments reset it.
|
||||
item.mtime = 12345
|
||||
return item
|
||||
|
|
@ -281,7 +309,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
item = self.create_item(**values)
|
||||
extension = item["format"].lower()
|
||||
item["path"] = os.path.join(
|
||||
_common.RSRC, util.bytestring_path("min." + extension)
|
||||
_common.RSRC, util.bytestring_path(f"min.{extension}")
|
||||
)
|
||||
item.add(self.lib)
|
||||
item.move(operation=MoveOperation.COPY)
|
||||
|
|
@ -296,7 +324,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
"""Add a number of items with files to the database."""
|
||||
# TODO base this on `add_item()`
|
||||
items = []
|
||||
path = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
|
||||
path = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
|
||||
for i in range(count):
|
||||
item = Item.from_path(path)
|
||||
item.album = f"\u00e4lbum {i}" # Check unicode paths
|
||||
|
|
@ -343,7 +371,7 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
specified extension a cover art image is added to the media
|
||||
file.
|
||||
"""
|
||||
src = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
|
||||
src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
|
||||
handle, path = mkstemp(dir=self.temp_dir)
|
||||
path = bytestring_path(path)
|
||||
os.close(handle)
|
||||
|
|
@ -383,16 +411,12 @@ class TestHelper(_common.Assertions, ConfigMixin):
|
|||
|
||||
# Safe file operations
|
||||
|
||||
def create_temp_dir(self, **kwargs):
|
||||
"""Create a temporary directory and assign it into
|
||||
`self.temp_dir`. Call `remove_temp_dir` later to delete it.
|
||||
"""
|
||||
temp_dir = mkdtemp(**kwargs)
|
||||
self.temp_dir = util.bytestring_path(temp_dir)
|
||||
def create_temp_dir(self, **kwargs) -> str:
|
||||
return mkdtemp(**kwargs)
|
||||
|
||||
def remove_temp_dir(self):
|
||||
"""Delete the temporary directory created by `create_temp_dir`."""
|
||||
shutil.rmtree(syspath(self.temp_dir))
|
||||
shutil.rmtree(self.temp_dir_path)
|
||||
|
||||
def touch(self, path, dir=None, content=""):
|
||||
"""Create a file at `path` with given content.
|
||||
|
|
@ -456,6 +480,11 @@ class PluginMixin(ConfigMixin):
|
|||
super().teardown_beets()
|
||||
self.unload_plugins()
|
||||
|
||||
def register_plugin(
|
||||
self, plugin_class: type[beets.plugins.BeetsPlugin]
|
||||
) -> None:
|
||||
beets.plugins._instances.append(plugin_class())
|
||||
|
||||
def load_plugins(self, *plugins: str) -> None:
|
||||
"""Load and initialize plugins by names.
|
||||
|
||||
|
|
@ -465,33 +494,15 @@ class PluginMixin(ConfigMixin):
|
|||
# FIXME this should eventually be handled by a plugin manager
|
||||
plugins = (self.plugin,) if hasattr(self, "plugin") else plugins
|
||||
self.config["plugins"] = plugins
|
||||
beets.plugins.load_plugins(plugins)
|
||||
beets.plugins.find_plugins()
|
||||
|
||||
# Take a backup of the original _types and _queries to restore
|
||||
# when unloading.
|
||||
Item._original_types = dict(Item._types)
|
||||
Album._original_types = dict(Album._types)
|
||||
Item._types.update(beets.plugins.types(Item))
|
||||
Album._types.update(beets.plugins.types(Album))
|
||||
|
||||
Item._original_queries = dict(Item._queries)
|
||||
Album._original_queries = dict(Album._queries)
|
||||
Item._queries.update(beets.plugins.named_queries(Item))
|
||||
Album._queries.update(beets.plugins.named_queries(Album))
|
||||
beets.plugins.load_plugins()
|
||||
|
||||
def unload_plugins(self) -> None:
|
||||
"""Unload all plugins and remove them from the configuration."""
|
||||
# FIXME this should eventually be handled by a plugin manager
|
||||
for plugin_class in beets.plugins._instances:
|
||||
plugin_class.listeners = None
|
||||
beets.plugins.BeetsPlugin.listeners.clear()
|
||||
beets.plugins.BeetsPlugin._raw_listeners.clear()
|
||||
self.config["plugins"] = []
|
||||
beets.plugins._classes = set()
|
||||
beets.plugins._instances = {}
|
||||
Item._types = getattr(Item, "_original_types", {})
|
||||
Album._types = getattr(Album, "_original_types", {})
|
||||
Item._queries = getattr(Item, "_original_queries", {})
|
||||
Album._queries = getattr(Album, "_original_queries", {})
|
||||
beets.plugins._instances.clear()
|
||||
|
||||
@contextmanager
|
||||
def configure_plugin(self, config: Any):
|
||||
|
|
@ -513,7 +524,6 @@ class ImportHelper(TestHelper):
|
|||
autotagging library and several assertions for the library.
|
||||
"""
|
||||
|
||||
resource_path = syspath(os.path.join(_common.RSRC, b"full.mp3"))
|
||||
default_import_config = {
|
||||
"autotag": True,
|
||||
"copy": True,
|
||||
|
|
@ -530,7 +540,7 @@ class ImportHelper(TestHelper):
|
|||
|
||||
@cached_property
|
||||
def import_path(self) -> Path:
|
||||
import_path = Path(os.fsdecode(self.temp_dir)) / "import"
|
||||
import_path = self.temp_dir_path / "import"
|
||||
import_path.mkdir(exist_ok=True)
|
||||
return import_path
|
||||
|
||||
|
|
@ -558,7 +568,7 @@ class ImportHelper(TestHelper):
|
|||
medium = MediaFile(track_path)
|
||||
medium.update(
|
||||
{
|
||||
"album": "Tag Album" + (f" {album_id}" if album_id else ""),
|
||||
"album": f"Tag Album{f' {album_id}' if album_id else ''}",
|
||||
"albumartist": None,
|
||||
"mb_albumid": None,
|
||||
"comp": None,
|
||||
|
|
@ -598,7 +608,7 @@ class ImportHelper(TestHelper):
|
|||
]
|
||||
|
||||
def prepare_albums_for_import(self, count: int = 1) -> None:
|
||||
album_dirs = Path(os.fsdecode(self.import_dir)).glob("album_*")
|
||||
album_dirs = self.import_path.glob("album_*")
|
||||
base_idx = int(str(max(album_dirs, default="0")).split("_")[-1]) + 1
|
||||
|
||||
for album_id in range(base_idx, count + base_idx):
|
||||
|
|
@ -622,21 +632,6 @@ class ImportHelper(TestHelper):
|
|||
def setup_singleton_importer(self, **kwargs) -> ImportSession:
|
||||
return self.setup_importer(singletons=True, **kwargs)
|
||||
|
||||
def assert_file_in_lib(self, *segments):
|
||||
"""Join the ``segments`` and assert that this path exists in the
|
||||
library directory.
|
||||
"""
|
||||
self.assertExists(os.path.join(self.libdir, *segments))
|
||||
|
||||
def assert_file_not_in_lib(self, *segments):
|
||||
"""Join the ``segments`` and assert that this path does not
|
||||
exist in the library directory.
|
||||
"""
|
||||
self.assertNotExists(os.path.join(self.libdir, *segments))
|
||||
|
||||
def assert_lib_dir_empty(self):
|
||||
assert not os.listdir(syspath(self.libdir))
|
||||
|
||||
|
||||
class AsIsImporterMixin:
|
||||
def setUp(self):
|
||||
|
|
@ -658,9 +653,9 @@ class ImportSessionFixture(ImportSession):
|
|||
|
||||
>>> lib = Library(':memory:')
|
||||
>>> importer = ImportSessionFixture(lib, paths=['/path/to/import'])
|
||||
>>> importer.add_choice(importer.action.SKIP)
|
||||
>>> importer.add_choice(importer.action.ASIS)
|
||||
>>> importer.default_choice = importer.action.APPLY
|
||||
>>> importer.add_choice(importer.Action.SKIP)
|
||||
>>> importer.add_choice(importer.Action.ASIS)
|
||||
>>> importer.default_choice = importer.Action.APPLY
|
||||
>>> importer.run()
|
||||
|
||||
This imports ``/path/to/import`` into `lib`. It skips the first
|
||||
|
|
@ -673,7 +668,7 @@ class ImportSessionFixture(ImportSession):
|
|||
self._choices = []
|
||||
self._resolutions = []
|
||||
|
||||
default_choice = importer.action.APPLY
|
||||
default_choice = importer.Action.APPLY
|
||||
|
||||
def add_choice(self, choice):
|
||||
self._choices.append(choice)
|
||||
|
|
@ -687,7 +682,7 @@ class ImportSessionFixture(ImportSession):
|
|||
except IndexError:
|
||||
choice = self.default_choice
|
||||
|
||||
if choice == importer.action.APPLY:
|
||||
if choice == importer.Action.APPLY:
|
||||
return task.candidates[0]
|
||||
elif isinstance(choice, int):
|
||||
return task.candidates[choice - 1]
|
||||
|
|
@ -707,7 +702,7 @@ class ImportSessionFixture(ImportSession):
|
|||
res = self.default_resolution
|
||||
|
||||
if res == self.Resolution.SKIP:
|
||||
task.set_choice(importer.action.SKIP)
|
||||
task.set_choice(importer.Action.SKIP)
|
||||
elif res == self.Resolution.REMOVE:
|
||||
task.should_remove_duplicates = True
|
||||
elif res == self.Resolution.MERGE:
|
||||
|
|
@ -720,7 +715,7 @@ class TerminalImportSessionFixture(TerminalImportSession):
|
|||
super().__init__(*args, **kwargs)
|
||||
self._choices = []
|
||||
|
||||
default_choice = importer.action.APPLY
|
||||
default_choice = importer.Action.APPLY
|
||||
|
||||
def add_choice(self, choice):
|
||||
self._choices.append(choice)
|
||||
|
|
@ -742,15 +737,15 @@ class TerminalImportSessionFixture(TerminalImportSession):
|
|||
except IndexError:
|
||||
choice = self.default_choice
|
||||
|
||||
if choice == importer.action.APPLY:
|
||||
if choice == importer.Action.APPLY:
|
||||
self.io.addinput("A")
|
||||
elif choice == importer.action.ASIS:
|
||||
elif choice == importer.Action.ASIS:
|
||||
self.io.addinput("U")
|
||||
elif choice == importer.action.ALBUMS:
|
||||
elif choice == importer.Action.ALBUMS:
|
||||
self.io.addinput("G")
|
||||
elif choice == importer.action.TRACKS:
|
||||
elif choice == importer.Action.TRACKS:
|
||||
self.io.addinput("T")
|
||||
elif choice == importer.action.SKIP:
|
||||
elif choice == importer.Action.SKIP:
|
||||
self.io.addinput("S")
|
||||
else:
|
||||
self.io.addinput("M")
|
||||
|
|
@ -758,7 +753,7 @@ class TerminalImportSessionFixture(TerminalImportSession):
|
|||
self._add_choice_input()
|
||||
|
||||
|
||||
class TerminalImportMixin(ImportHelper):
|
||||
class TerminalImportMixin(IOMixin, ImportHelper):
|
||||
"""Provides_a terminal importer for the import session."""
|
||||
|
||||
io: _common.DummyIO
|
||||
|
|
@ -774,6 +769,7 @@ class TerminalImportMixin(ImportHelper):
|
|||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutotagStub:
|
||||
"""Stub out MusicBrainz album and track matcher and control what the
|
||||
autotagger returns.
|
||||
|
|
@ -784,47 +780,44 @@ class AutotagStub:
|
|||
GOOD = "GOOD"
|
||||
BAD = "BAD"
|
||||
MISSING = "MISSING"
|
||||
"""Generate an album match for all but one track
|
||||
"""
|
||||
matching: str
|
||||
|
||||
length = 2
|
||||
matching = IDENT
|
||||
|
||||
def install(self):
|
||||
self.mb_match_album = autotag.mb.match_album
|
||||
self.mb_match_track = autotag.mb.match_track
|
||||
self.mb_album_for_id = autotag.mb.album_for_id
|
||||
self.mb_track_for_id = autotag.mb.track_for_id
|
||||
|
||||
autotag.mb.match_album = self.match_album
|
||||
autotag.mb.match_track = self.match_track
|
||||
autotag.mb.album_for_id = self.album_for_id
|
||||
autotag.mb.track_for_id = self.track_for_id
|
||||
self.patchers = [
|
||||
patch("beets.metadata_plugins.album_for_id", lambda *_: None),
|
||||
patch("beets.metadata_plugins.track_for_id", lambda *_: None),
|
||||
patch("beets.metadata_plugins.candidates", self.candidates),
|
||||
patch(
|
||||
"beets.metadata_plugins.item_candidates", self.item_candidates
|
||||
),
|
||||
]
|
||||
for p in self.patchers:
|
||||
p.start()
|
||||
|
||||
return self
|
||||
|
||||
def restore(self):
|
||||
autotag.mb.match_album = self.mb_match_album
|
||||
autotag.mb.match_track = self.mb_match_track
|
||||
autotag.mb.album_for_id = self.mb_album_for_id
|
||||
autotag.mb.track_for_id = self.mb_track_for_id
|
||||
for p in self.patchers:
|
||||
p.stop()
|
||||
|
||||
def match_album(self, albumartist, album, tracks, extra_tags):
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
if self.matching == self.IDENT:
|
||||
yield self._make_album_match(albumartist, album, tracks)
|
||||
yield self._make_album_match(artist, album, len(items))
|
||||
|
||||
elif self.matching == self.GOOD:
|
||||
for i in range(self.length):
|
||||
yield self._make_album_match(albumartist, album, tracks, i)
|
||||
yield self._make_album_match(artist, album, len(items), i)
|
||||
|
||||
elif self.matching == self.BAD:
|
||||
for i in range(self.length):
|
||||
yield self._make_album_match(albumartist, album, tracks, i + 1)
|
||||
yield self._make_album_match(artist, album, len(items), i + 1)
|
||||
|
||||
elif self.matching == self.MISSING:
|
||||
yield self._make_album_match(albumartist, album, tracks, missing=1)
|
||||
yield self._make_album_match(artist, album, len(items), missing=1)
|
||||
|
||||
def match_track(self, artist, title):
|
||||
def item_candidates(self, item, artist, title):
|
||||
yield TrackInfo(
|
||||
title=title.replace("Tag", "Applied"),
|
||||
track_id="trackid",
|
||||
|
|
@ -834,31 +827,23 @@ class AutotagStub:
|
|||
index=0,
|
||||
)
|
||||
|
||||
def album_for_id(self, mbid):
|
||||
return None
|
||||
|
||||
def track_for_id(self, mbid):
|
||||
return None
|
||||
|
||||
def _make_track_match(self, artist, album, number):
|
||||
return TrackInfo(
|
||||
title="Applied Track %d" % number,
|
||||
track_id="match %d" % number,
|
||||
title=f"Applied Track {number}",
|
||||
track_id=f"match {number}",
|
||||
artist=artist,
|
||||
length=1,
|
||||
index=0,
|
||||
)
|
||||
|
||||
def _make_album_match(self, artist, album, tracks, distance=0, missing=0):
|
||||
if distance:
|
||||
id = " " + "M" * distance
|
||||
else:
|
||||
id = ""
|
||||
id = f" {'M' * distance}" if distance else ""
|
||||
|
||||
if artist is None:
|
||||
artist = "Various Artists"
|
||||
else:
|
||||
artist = artist.replace("Tag", "Applied") + id
|
||||
album = album.replace("Tag", "Applied") + id
|
||||
artist = f"{artist.replace('Tag', 'Applied')}{id}"
|
||||
album = f"{album.replace('Tag', 'Applied')}{id}"
|
||||
|
||||
track_infos = []
|
||||
for i in range(tracks - missing):
|
||||
|
|
@ -869,14 +854,23 @@ class AutotagStub:
|
|||
album=album,
|
||||
tracks=track_infos,
|
||||
va=False,
|
||||
album_id="albumid" + id,
|
||||
artist_id="artistid" + id,
|
||||
album_id=f"albumid{id}",
|
||||
artist_id=f"artistid{id}",
|
||||
albumtype="soundtrack",
|
||||
data_source="match_source",
|
||||
bandcamp_album_id="bc_url",
|
||||
)
|
||||
|
||||
|
||||
class AutotagImportTestCase(ImportTestCase):
|
||||
matching = AutotagStub.IDENT
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.matcher = AutotagStub(self.matching).install()
|
||||
self.addCleanup(self.matcher.restore)
|
||||
|
||||
|
||||
class FetchImageHelper:
|
||||
"""Helper mixin for mocking requests when fetching images
|
||||
with remote art sources.
|
||||
|
|
@ -886,20 +880,43 @@ class FetchImageHelper:
|
|||
def run(self, *args, **kwargs):
|
||||
super().run(*args, **kwargs)
|
||||
|
||||
IMAGEHEADER = {
|
||||
"image/jpeg": b"\xff\xd8\xff" + b"\x00" * 3 + b"JFIF",
|
||||
IMAGEHEADER: dict[str, bytes] = {
|
||||
"image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF",
|
||||
"image/png": b"\211PNG\r\n\032\n",
|
||||
"image/gif": b"GIF89a",
|
||||
# dummy type that is definitely not a valid image content type
|
||||
"image/watercolour": b"watercolour",
|
||||
"text/html": (
|
||||
b"<!DOCTYPE html>\n<html>\n<head>\n</head>\n"
|
||||
b"<body>\n</body>\n</html>"
|
||||
),
|
||||
}
|
||||
|
||||
def mock_response(self, url, content_type="image/jpeg", file_type=None):
|
||||
def mock_response(
|
||||
self,
|
||||
url: str,
|
||||
content_type: str = "image/jpeg",
|
||||
file_type: None | str = None,
|
||||
) -> None:
|
||||
# Potentially return a file of a type that differs from the
|
||||
# server-advertised content type to mimic misbehaving servers.
|
||||
if file_type is None:
|
||||
file_type = content_type
|
||||
|
||||
try:
|
||||
# imghdr reads 32 bytes
|
||||
header = self.IMAGEHEADER[file_type].ljust(32, b"\x00")
|
||||
except KeyError:
|
||||
# If we can't return a file that looks like real file of the requested
|
||||
# type, better fail the test than returning something else, which might
|
||||
# violate assumption made when writing a test.
|
||||
raise AssertionError(f"Mocking {file_type} responses not supported")
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
url,
|
||||
content_type=content_type,
|
||||
# imghdr reads 32 bytes
|
||||
body=self.IMAGEHEADER.get(file_type, b"").ljust(32, b"\x00"),
|
||||
body=header,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
2517
beets/ui/commands.py
2517
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
|
||||
566
beets/ui/commands/import_/display.py
Normal file
566
beets/ui/commands/import_/display.py
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
import confuse
|
||||
|
||||
from beets.autotag.distance import Distance
|
||||
from beets.library.models import Item
|
||||
from beets.ui import ColorName
|
||||
|
||||
VARIOUS_ARTISTS = "Various Artists"
|
||||
|
||||
|
||||
class Side(TypedDict):
|
||||
prefix: str
|
||||
contents: str
|
||||
suffix: str
|
||||
width: NotRequired[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeRepresentation:
|
||||
"""Keeps track of all information needed to generate a (colored) text
|
||||
representation of the changes that will be made if an album or singleton's
|
||||
tags are changed according to `match`, which must be an AlbumMatch or
|
||||
TrackMatch object, accordingly.
|
||||
"""
|
||||
|
||||
cur_artist: str
|
||||
cur_name: str
|
||||
match: autotag.hooks.Match
|
||||
|
||||
@cached_property
|
||||
def changed_prefix(self) -> str:
|
||||
return ui.colorize("changed", "\u2260")
|
||||
|
||||
@cached_property
|
||||
def _indentation_config(self) -> confuse.Subview:
|
||||
return config["ui"]["import"]["indentation"]
|
||||
|
||||
@cached_property
|
||||
def indent_header(self) -> str:
|
||||
return ui.indent(self._indentation_config["match_header"].as_number())
|
||||
|
||||
@cached_property
|
||||
def indent_detail(self) -> str:
|
||||
return ui.indent(self._indentation_config["match_details"].as_number())
|
||||
|
||||
@cached_property
|
||||
def indent_tracklist(self) -> str:
|
||||
return ui.indent(
|
||||
self._indentation_config["match_tracklist"].as_number()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def layout(self) -> int:
|
||||
return config["ui"]["import"]["layout"].as_choice(
|
||||
{"column": 0, "newline": 1}
|
||||
)
|
||||
|
||||
def print_layout(
|
||||
self,
|
||||
indent: str,
|
||||
left: Side,
|
||||
right: Side,
|
||||
separator: str = " -> ",
|
||||
max_width: int | None = None,
|
||||
) -> 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) -> None:
|
||||
"""Print out a 'header' identifying the suggested match (album name,
|
||||
artist name,...) and summarizing the changes that would be made should
|
||||
the user accept the match.
|
||||
"""
|
||||
# Print newline at beginning of change block.
|
||||
ui.print_("")
|
||||
|
||||
# 'Match' line and similarity.
|
||||
ui.print_(
|
||||
f"{self.indent_header}Match ({dist_string(self.match.distance)}):"
|
||||
)
|
||||
|
||||
artist_name_str = f"{self.match.info.artist} - {self.match.info.name}"
|
||||
ui.print_(
|
||||
self.indent_header
|
||||
+ dist_colorize(artist_name_str, self.match.distance)
|
||||
)
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(self.match.distance)
|
||||
if penalties:
|
||||
ui.print_(f"{self.indent_header}{penalties}")
|
||||
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(self.match.info)
|
||||
if disambig:
|
||||
ui.print_(f"{self.indent_header}{disambig}")
|
||||
|
||||
# Data URL.
|
||||
if self.match.info.data_url:
|
||||
url = ui.colorize("text_faint", f"{self.match.info.data_url}")
|
||||
ui.print_(f"{self.indent_header}{url}")
|
||||
|
||||
def show_match_details(self) -> None:
|
||||
"""Print out the details of the match, including changes in album name
|
||||
and artist name.
|
||||
"""
|
||||
# Artist.
|
||||
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
|
||||
if artist_r == VARIOUS_ARTISTS:
|
||||
# Hide artists for VA releases.
|
||||
artist_l, artist_r = "", ""
|
||||
left: Side
|
||||
right: Side
|
||||
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_name:
|
||||
type_ = self.match.type
|
||||
name_l, name_r = self.cur_name or "", self.match.info.name
|
||||
if self.cur_name != self.match.info.name != VARIOUS_ARTISTS:
|
||||
name_l, name_r = ui.colordiff(name_l, name_r)
|
||||
left = {
|
||||
"prefix": f"{self.changed_prefix} {type_}: ",
|
||||
"contents": name_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": name_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
else:
|
||||
ui.print_(f"{self.indent_detail}*", f"{type_}:", name_r)
|
||||
|
||||
def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str:
|
||||
"""Construct a line with the current medium's info."""
|
||||
track_media = track_info.get("media", "Media")
|
||||
# Build output string.
|
||||
if self.match.info.mediums > 1 and track_info.disctitle:
|
||||
return (
|
||||
f"* {track_media} {track_info.medium}: {track_info.disctitle}"
|
||||
)
|
||||
elif self.match.info.mediums > 1:
|
||||
return f"* {track_media} {track_info.medium}"
|
||||
elif track_info.disctitle:
|
||||
return f"* {track_media}: {track_info.disctitle}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def format_index(self, track_info: hooks.TrackInfo | Item) -> str:
|
||||
"""Return a string representing the track index of the given
|
||||
TrackInfo or Item object.
|
||||
"""
|
||||
if isinstance(track_info, hooks.TrackInfo):
|
||||
index = track_info.index
|
||||
medium_index = track_info.medium_index
|
||||
medium = track_info.medium
|
||||
mediums = self.match.info.mediums
|
||||
else:
|
||||
index = medium_index = track_info.track
|
||||
medium = track_info.disc
|
||||
mediums = track_info.disctotal
|
||||
if config["per_disc_numbering"]:
|
||||
if mediums and mediums > 1:
|
||||
return f"{medium}-{medium_index}"
|
||||
else:
|
||||
return str(medium_index if medium_index is not None else index)
|
||||
else:
|
||||
return str(index)
|
||||
|
||||
def make_track_numbers(
|
||||
self, item: Item, track_info: hooks.TrackInfo
|
||||
) -> tuple[str, str, bool]:
|
||||
"""Format colored track indices."""
|
||||
cur_track = self.format_index(item)
|
||||
new_track = self.format_index(track_info)
|
||||
changed = False
|
||||
# Choose color based on change.
|
||||
highlight_color: ColorName
|
||||
if cur_track != new_track:
|
||||
changed = True
|
||||
if item.track in (track_info.index, track_info.medium_index):
|
||||
highlight_color = "text_highlight_minor"
|
||||
else:
|
||||
highlight_color = "text_highlight"
|
||||
else:
|
||||
highlight_color = "text_faint"
|
||||
|
||||
lhs_track = 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: Item, track_info: hooks.TrackInfo
|
||||
) -> tuple[str, str, bool]:
|
||||
"""Format colored track titles."""
|
||||
new_title = track_info.name
|
||||
if not item.title.strip():
|
||||
# If there's no title, we use the filename. Don't colordiff.
|
||||
cur_title = displayable_path(os.path.basename(item.path))
|
||||
return cur_title, new_title, True
|
||||
else:
|
||||
# If there is a title, highlight differences.
|
||||
cur_title = item.title.strip()
|
||||
cur_col, new_col = ui.colordiff(cur_title, new_title)
|
||||
return cur_col, new_col, cur_title != new_title
|
||||
|
||||
@staticmethod
|
||||
def make_track_lengths(
|
||||
item: Item, track_info: hooks.TrackInfo
|
||||
) -> tuple[str, str, bool]:
|
||||
"""Format colored track lengths."""
|
||||
changed = False
|
||||
highlight_color: ColorName
|
||||
if (
|
||||
item.length
|
||||
and track_info.length
|
||||
and abs(item.length - track_info.length)
|
||||
>= config["ui"]["length_diff_thresh"].as_number()
|
||||
):
|
||||
highlight_color = "text_highlight"
|
||||
changed = True
|
||||
else:
|
||||
highlight_color = "text_highlight_minor"
|
||||
|
||||
# Handle nonetype lengths by setting to 0
|
||||
cur_length0 = item.length if item.length else 0
|
||||
new_length0 = track_info.length if track_info.length else 0
|
||||
# format into string
|
||||
cur_length = f"({human_seconds_short(cur_length0)})"
|
||||
new_length = f"({human_seconds_short(new_length0)})"
|
||||
# colorize
|
||||
lhs_length = 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: Item, track_info: hooks.TrackInfo
|
||||
) -> tuple[Side, Side]:
|
||||
"""Extract changes from item -> new TrackInfo object, and colorize
|
||||
appropriately. Returns (lhs, rhs) for column printing.
|
||||
"""
|
||||
# Track titles.
|
||||
lhs_title, rhs_title, diff_title = self.make_track_titles(
|
||||
item, track_info
|
||||
)
|
||||
# Track number change.
|
||||
lhs_track, rhs_track, diff_track = self.make_track_numbers(
|
||||
item, track_info
|
||||
)
|
||||
# Length change.
|
||||
lhs_length, rhs_length, diff_length = self.make_track_lengths(
|
||||
item, track_info
|
||||
)
|
||||
|
||||
changed = diff_title or diff_track or diff_length
|
||||
|
||||
# Construct lhs and rhs dicts.
|
||||
# Previously, we printed the penalties, however this is no longer
|
||||
# the case, thus the 'info' dictionary is unneeded.
|
||||
# penalties = penalty_string(self.match.distance.tracks[track_info])
|
||||
|
||||
lhs: Side = {
|
||||
"prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ",
|
||||
"contents": lhs_title,
|
||||
"suffix": f" {lhs_length}",
|
||||
}
|
||||
rhs: Side = {"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: list[tuple[Side, Side]]) -> None:
|
||||
"""Calculates column widths for tracks stored as line tuples:
|
||||
(left, right). Then prints each line of tracklist.
|
||||
"""
|
||||
if len(lines) == 0:
|
||||
# If no lines provided, e.g. details not required, do nothing.
|
||||
return
|
||||
|
||||
def get_width(side: Side) -> int:
|
||||
"""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):
|
||||
match: autotag.hooks.AlbumMatch
|
||||
|
||||
def show_match_tracks(self) -> None:
|
||||
"""Print out the tracks of the match, summarizing changes the match
|
||||
suggests for them.
|
||||
"""
|
||||
pairs = sorted(
|
||||
self.match.item_info_pairs, key=lambda pair: pair[1].index or 0
|
||||
)
|
||||
# Build up LHS and RHS for track difference display. The `lines` list
|
||||
# contains `(left, right)` tuples.
|
||||
lines: list[tuple[Side, Side]] = []
|
||||
medium = disctitle = None
|
||||
for item, track_info in pairs:
|
||||
# If the track is the first on a new medium, show medium
|
||||
# number and title.
|
||||
if medium != track_info.medium or disctitle != track_info.disctitle:
|
||||
# Create header for new medium
|
||||
header = self.make_medium_info_line(track_info)
|
||||
if header != "":
|
||||
# Print tracks from previous medium
|
||||
self.print_tracklist(lines)
|
||||
lines = []
|
||||
ui.print_(f"{self.indent_detail}{header}")
|
||||
# Save new medium details for future comparison.
|
||||
medium, disctitle = track_info.medium, track_info.disctitle
|
||||
|
||||
# Construct the line tuple for the track.
|
||||
left, right = self.make_line(item, track_info)
|
||||
if right["contents"] != "":
|
||||
lines.append((left, right))
|
||||
else:
|
||||
if config["import"]["detail"]:
|
||||
lines.append((left, right))
|
||||
self.print_tracklist(lines)
|
||||
|
||||
# Missing and unmatched tracks.
|
||||
if self.match.extra_tracks:
|
||||
ui.print_(
|
||||
"Missing tracks"
|
||||
f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -"
|
||||
f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):"
|
||||
)
|
||||
for track_info in self.match.extra_tracks:
|
||||
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
|
||||
if track_info.length:
|
||||
line += f" ({human_seconds_short(track_info.length)})"
|
||||
ui.print_(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."""
|
||||
|
||||
match: autotag.hooks.TrackMatch
|
||||
|
||||
|
||||
def show_change(
|
||||
cur_artist: str, cur_album: str, match: hooks.AlbumMatch
|
||||
) -> None:
|
||||
"""Print out a representation of the changes that will be made if an
|
||||
album's tags are changed according to `match`, which must be an AlbumMatch
|
||||
object.
|
||||
"""
|
||||
change = AlbumChange(cur_artist, cur_album, match)
|
||||
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
# Print the match tracks.
|
||||
change.show_match_tracks()
|
||||
|
||||
|
||||
def show_item_change(item: Item, match: hooks.TrackMatch) -> None:
|
||||
"""Print out the change that would occur by tagging `item` with the
|
||||
metadata from `match`, a TrackMatch object.
|
||||
"""
|
||||
change = TrackChange(item.artist, item.title, match)
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
|
||||
def disambig_string(info: hooks.Info) -> str:
|
||||
"""Generate a string for an AlbumInfo or TrackInfo object that
|
||||
provides context that helps disambiguate similar-looking albums and
|
||||
tracks.
|
||||
"""
|
||||
if isinstance(info, hooks.AlbumInfo):
|
||||
disambig = get_album_disambig_fields(info)
|
||||
elif isinstance(info, hooks.TrackInfo):
|
||||
disambig = get_singleton_disambig_fields(info)
|
||||
else:
|
||||
return ""
|
||||
|
||||
return ", ".join(disambig)
|
||||
|
||||
|
||||
def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
|
||||
out = []
|
||||
chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
|
||||
calculated_values = {
|
||||
"index": f"Index {info.index}",
|
||||
"track_alt": f"Track {info.track_alt}",
|
||||
"album": (
|
||||
f"[{info.album}]"
|
||||
if (
|
||||
config["import"]["singleton_album_disambig"].get()
|
||||
and info.get("album")
|
||||
)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
for field in chosen_fields:
|
||||
if field in calculated_values:
|
||||
out.append(str(calculated_values[field]))
|
||||
else:
|
||||
try:
|
||||
out.append(str(info[field]))
|
||||
except (AttributeError, KeyError):
|
||||
print(f"Disambiguation string key {field} does not exist.")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
|
||||
out = []
|
||||
chosen_fields = config["match"]["album_disambig_fields"].as_str_seq()
|
||||
calculated_values = {
|
||||
"media": (
|
||||
f"{info.mediums}x{info.media}"
|
||||
if (info.mediums and info.mediums > 1)
|
||||
else info.media
|
||||
),
|
||||
}
|
||||
|
||||
for field in chosen_fields:
|
||||
if field in calculated_values:
|
||||
out.append(str(calculated_values[field]))
|
||||
else:
|
||||
try:
|
||||
out.append(str(info[field]))
|
||||
except (AttributeError, KeyError):
|
||||
print(f"Disambiguation string key {field} does not exist.")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def dist_colorize(string: str, dist: Distance) -> str:
|
||||
"""Formats a string as a colorized similarity string according to
|
||||
a distance.
|
||||
"""
|
||||
if dist <= config["match"]["strong_rec_thresh"].as_number():
|
||||
string = 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: Distance) -> str:
|
||||
"""Formats a distance (a float) as a colorized similarity percentage
|
||||
string.
|
||||
"""
|
||||
string = f"{(1 - dist) * 100:.1f}%"
|
||||
return dist_colorize(string, dist)
|
||||
|
||||
|
||||
def penalty_string(distance: Distance, limit: int | None = None) -> str:
|
||||
"""Returns a colorized string that indicates all the penalties
|
||||
applied to a distance object.
|
||||
"""
|
||||
penalties = []
|
||||
for key in distance.keys():
|
||||
key = key.replace("album_", "")
|
||||
key = key.replace("track_", "")
|
||||
key = key.replace("_", " ")
|
||||
penalties.append(key)
|
||||
if penalties:
|
||||
if limit and len(penalties) > limit:
|
||||
penalties = penalties[:limit] + ["..."]
|
||||
# Prefix penalty string with U+2260: Not Equal To
|
||||
penalty_string = f"\u2260 {', '.join(penalties)}"
|
||||
return ui.colorize("changed", penalty_string)
|
||||
|
||||
return ""
|
||||
547
beets/ui/commands/import_/session.py
Normal file
547
beets/ui/commands/import_/session.py
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
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} - {match.info.name}"
|
||||
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
|
||||
|
|
@ -28,8 +28,10 @@ import sys
|
|||
import tempfile
|
||||
import traceback
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
from functools import cache
|
||||
from importlib import import_module
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from pathlib import Path
|
||||
|
|
@ -38,33 +40,36 @@ from typing import (
|
|||
TYPE_CHECKING,
|
||||
Any,
|
||||
AnyStr,
|
||||
Callable,
|
||||
Iterable,
|
||||
ClassVar,
|
||||
Generic,
|
||||
NamedTuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
import beets
|
||||
from beets.util import hidden
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Iterable, Iterator
|
||||
from logging import Logger
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else:
|
||||
from typing_extensions import TypeAlias
|
||||
from beets.library import Item
|
||||
|
||||
|
||||
MAX_FILENAME_LENGTH = 200
|
||||
WINDOWS_MAGIC_PREFIX = "\\\\?\\"
|
||||
T = TypeVar("T")
|
||||
BytesOrStr = Union[str, bytes]
|
||||
PathLike = Union[BytesOrStr, Path]
|
||||
Replacements: TypeAlias = "Sequence[tuple[Pattern[str], str]]"
|
||||
PathLike = Union[str, bytes, Path]
|
||||
StrPath = Union[str, Path]
|
||||
Replacements = Sequence[tuple[Pattern[str], str]]
|
||||
|
||||
# Here for now to allow for a easy replace later on
|
||||
# once we can move to a PathLike (mainly used in importer)
|
||||
PathBytes = bytes
|
||||
|
||||
|
||||
class HumanReadableError(Exception):
|
||||
|
|
@ -106,7 +111,7 @@ class HumanReadableError(Exception):
|
|||
elif hasattr(self.reason, "strerror"): # i.e., EnvironmentError
|
||||
return self.reason.strerror
|
||||
else:
|
||||
return '"{}"'.format(str(self.reason))
|
||||
return f'"{self.reason}"'
|
||||
|
||||
def get_message(self):
|
||||
"""Create the human-readable description of the error, sans
|
||||
|
|
@ -120,7 +125,7 @@ class HumanReadableError(Exception):
|
|||
"""
|
||||
if self.tb:
|
||||
logger.debug(self.tb)
|
||||
logger.error("{0}: {1}", self.error_kind, self.args[0])
|
||||
logger.error("{0.error_kind}: {0.args[0]}", self)
|
||||
|
||||
|
||||
class FilesystemError(HumanReadableError):
|
||||
|
|
@ -136,18 +141,16 @@ class FilesystemError(HumanReadableError):
|
|||
def get_message(self):
|
||||
# Use a nicer English phrasing for some specific verbs.
|
||||
if self.verb in ("move", "copy", "rename"):
|
||||
clause = "while {} {} to {}".format(
|
||||
self._gerund(),
|
||||
displayable_path(self.paths[0]),
|
||||
displayable_path(self.paths[1]),
|
||||
clause = (
|
||||
f"while {self._gerund()} {displayable_path(self.paths[0])} to"
|
||||
f" {displayable_path(self.paths[1])}"
|
||||
)
|
||||
elif self.verb in ("delete", "write", "create", "read"):
|
||||
clause = "while {} {}".format(
|
||||
self._gerund(), displayable_path(self.paths[0])
|
||||
)
|
||||
clause = f"while {self._gerund()} {displayable_path(self.paths[0])}"
|
||||
else:
|
||||
clause = "during {} of paths {}".format(
|
||||
self.verb, ", ".join(displayable_path(p) for p in self.paths)
|
||||
clause = (
|
||||
f"during {self.verb} of paths"
|
||||
f" {', '.join(displayable_path(p) for p in self.paths)}"
|
||||
)
|
||||
|
||||
return f"{self._reasonstr()} {clause}"
|
||||
|
|
@ -164,6 +167,12 @@ class MoveOperation(Enum):
|
|||
REFLINK_AUTO = 5
|
||||
|
||||
|
||||
class PromptChoice(NamedTuple):
|
||||
short: str
|
||||
long: str
|
||||
callback: Any
|
||||
|
||||
|
||||
def normpath(path: PathLike) -> bytes:
|
||||
"""Provide the canonical form of the path suitable for storing in
|
||||
the database.
|
||||
|
|
@ -217,12 +226,12 @@ def sorted_walk(
|
|||
# Get all the directories and files at this level.
|
||||
try:
|
||||
contents = os.listdir(syspath(bytes_path))
|
||||
except OSError as exc:
|
||||
except OSError:
|
||||
if logger:
|
||||
logger.warning(
|
||||
"could not list directory {}: {}".format(
|
||||
displayable_path(bytes_path), exc.strerror
|
||||
)
|
||||
"could not list directory {}",
|
||||
displayable_path(bytes_path),
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
dirs = []
|
||||
|
|
@ -430,8 +439,8 @@ def syspath(path: PathLike, prefix: bool = True) -> str:
|
|||
if prefix and not str_path.startswith(WINDOWS_MAGIC_PREFIX):
|
||||
if str_path.startswith("\\\\"):
|
||||
# UNC path. Final path should look like \\?\UNC\...
|
||||
str_path = "UNC" + str_path[1:]
|
||||
str_path = WINDOWS_MAGIC_PREFIX + str_path
|
||||
str_path = f"UNC{str_path[1:]}"
|
||||
str_path = f"{WINDOWS_MAGIC_PREFIX}{str_path}"
|
||||
|
||||
return str_path
|
||||
|
||||
|
|
@ -503,8 +512,8 @@ def move(path: bytes, dest: bytes, replace: bool = False):
|
|||
basename = os.path.basename(bytestring_path(dest))
|
||||
dirname = os.path.dirname(bytestring_path(dest))
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=syspath(b".beets", prefix=False),
|
||||
prefix=syspath(b"." + basename + b".", prefix=False),
|
||||
suffix=".beets",
|
||||
prefix=f".{os.fsdecode(basename)}.",
|
||||
dir=syspath(dirname),
|
||||
delete=False,
|
||||
)
|
||||
|
|
@ -557,7 +566,7 @@ def link(path: bytes, dest: bytes, replace: bool = False):
|
|||
except NotImplementedError:
|
||||
# raised on python >= 3.2 and Windows versions before Vista
|
||||
raise FilesystemError(
|
||||
"OS does not support symbolic links." "link",
|
||||
"OS does not support symbolic links.link",
|
||||
(path, dest),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
|
@ -573,20 +582,24 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False):
|
|||
if samefile(path, dest):
|
||||
return
|
||||
|
||||
if os.path.exists(syspath(dest)) and not replace:
|
||||
# Dereference symlinks, expand "~", and convert relative paths to absolute
|
||||
origin_path = Path(os.fsdecode(path)).expanduser().resolve()
|
||||
dest_path = Path(os.fsdecode(dest)).expanduser().resolve()
|
||||
|
||||
if dest_path.exists() and not replace:
|
||||
raise FilesystemError("file exists", "rename", (path, dest))
|
||||
try:
|
||||
os.link(syspath(path), syspath(dest))
|
||||
dest_path.hardlink_to(origin_path)
|
||||
except NotImplementedError:
|
||||
raise FilesystemError(
|
||||
"OS does not support hard links." "link",
|
||||
"OS does not support hard links.link",
|
||||
(path, dest),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EXDEV:
|
||||
raise FilesystemError(
|
||||
"Cannot hard link across devices." "link",
|
||||
"Cannot hard link across devices.link",
|
||||
(path, dest),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
|
@ -694,105 +707,87 @@ def sanitize_path(path: str, replacements: Replacements | None = None) -> str:
|
|||
return os.path.join(*comps)
|
||||
|
||||
|
||||
def truncate_path(path: AnyStr, length: int = MAX_FILENAME_LENGTH) -> AnyStr:
|
||||
"""Given a bytestring path or a Unicode path fragment, truncate the
|
||||
components to a legal length. In the last component, the extension
|
||||
is preserved.
|
||||
def truncate_str(s: str, length: int) -> str:
|
||||
"""Truncate the string to the given byte length.
|
||||
|
||||
If we end up truncating a unicode character in the middle (rendering it invalid),
|
||||
it is removed:
|
||||
|
||||
>>> s = "🎹🎶" # 8 bytes
|
||||
>>> truncate_str(s, 6)
|
||||
'🎹'
|
||||
"""
|
||||
comps = components(path)
|
||||
return os.fsencode(s)[:length].decode(sys.getfilesystemencoding(), "ignore")
|
||||
|
||||
out = [c[:length] for c in comps]
|
||||
base, ext = os.path.splitext(comps[-1])
|
||||
if ext:
|
||||
# Last component has an extension.
|
||||
base = base[: length - len(ext)]
|
||||
out[-1] = base + ext
|
||||
|
||||
return os.path.join(*out)
|
||||
def truncate_path(str_path: str) -> str:
|
||||
"""Truncate each path part to a legal length preserving the extension."""
|
||||
max_length = get_max_filename_length()
|
||||
path = Path(str_path)
|
||||
parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]]
|
||||
stem = truncate_str(path.stem, max_length - len(path.suffix))
|
||||
return f"{Path(*parent_parts, stem)}{path.suffix}"
|
||||
|
||||
|
||||
def _legalize_stage(
|
||||
path: str,
|
||||
replacements: Replacements | None,
|
||||
length: int,
|
||||
extension: str,
|
||||
fragment: bool,
|
||||
) -> tuple[BytesOrStr, bool]:
|
||||
path: str, replacements: Replacements | None, extension: str
|
||||
) -> tuple[str, bool]:
|
||||
"""Perform a single round of path legalization steps
|
||||
(sanitation/replacement, encoding from Unicode to bytes,
|
||||
extension-appending, and truncation). Return the path (Unicode if
|
||||
`fragment` is set, `bytes` otherwise) and whether truncation was
|
||||
required.
|
||||
1. sanitation/replacement
|
||||
2. appending the extension
|
||||
3. truncation.
|
||||
|
||||
Return the path and whether truncation was required.
|
||||
"""
|
||||
# Perform an initial sanitization including user replacements.
|
||||
path = sanitize_path(path, replacements)
|
||||
|
||||
# Encode for the filesystem.
|
||||
if not fragment:
|
||||
path = bytestring_path(path) # type: ignore
|
||||
|
||||
# Preserve extension.
|
||||
path += extension.lower()
|
||||
|
||||
# Truncate too-long components.
|
||||
pre_truncate_path = path
|
||||
path = truncate_path(path, length)
|
||||
path = truncate_path(path)
|
||||
|
||||
return path, path != pre_truncate_path
|
||||
|
||||
|
||||
def legalize_path(
|
||||
path: str,
|
||||
replacements: Replacements | None,
|
||||
length: int,
|
||||
extension: bytes,
|
||||
fragment: bool,
|
||||
) -> tuple[BytesOrStr, bool]:
|
||||
"""Given a path-like Unicode string, produce a legal path. Return
|
||||
the path and a flag indicating whether some replacements had to be
|
||||
ignored (see below).
|
||||
path: str, replacements: Replacements | None, extension: str
|
||||
) -> tuple[str, bool]:
|
||||
"""Given a path-like Unicode string, produce a legal path. Return the path
|
||||
and a flag indicating whether some replacements had to be ignored (see
|
||||
below).
|
||||
|
||||
The legalization process (see `_legalize_stage`) consists of
|
||||
applying the sanitation rules in `replacements`, encoding the string
|
||||
to bytes (unless `fragment` is set), truncating components to
|
||||
`length`, appending the `extension`.
|
||||
This function uses `_legalize_stage` function to legalize the path, see its
|
||||
documentation for the details of what this involves. It is called up to
|
||||
three times in case truncation conflicts with replacements (as can happen
|
||||
when truncation creates whitespace at the end of the string, for example).
|
||||
|
||||
This function performs up to three calls to `_legalize_stage` in
|
||||
case truncation conflicts with replacements (as can happen when
|
||||
truncation creates whitespace at the end of the string, for
|
||||
example). The limited number of iterations iterations avoids the
|
||||
possibility of an infinite loop of sanitation and truncation
|
||||
operations, which could be caused by replacement rules that make the
|
||||
string longer. The flag returned from this function indicates that
|
||||
the path has to be truncated twice (indicating that replacements
|
||||
made the string longer again after it was truncated); the
|
||||
application should probably log some sort of warning.
|
||||
The limited number of iterations avoids the possibility of an infinite loop
|
||||
of sanitation and truncation operations, which could be caused by
|
||||
replacement rules that make the string longer.
|
||||
|
||||
The flag returned from this function indicates that the path has to be
|
||||
truncated twice (indicating that replacements made the string longer again
|
||||
after it was truncated); the application should probably log some sort of
|
||||
warning.
|
||||
"""
|
||||
suffix = as_string(extension)
|
||||
|
||||
if fragment:
|
||||
# Outputting Unicode.
|
||||
extension = extension.decode("utf-8", "ignore")
|
||||
|
||||
first_stage_path, _ = _legalize_stage(
|
||||
path, replacements, length, extension, fragment
|
||||
first_stage, _ = os.path.splitext(
|
||||
_legalize_stage(path, replacements, suffix)[0]
|
||||
)
|
||||
|
||||
# Convert back to Unicode with extension removed.
|
||||
first_stage_path, _ = os.path.splitext(displayable_path(first_stage_path))
|
||||
|
||||
# Re-sanitize following truncation (including user replacements).
|
||||
second_stage_path, retruncated = _legalize_stage(
|
||||
first_stage_path, replacements, length, extension, fragment
|
||||
)
|
||||
second_stage, truncated = _legalize_stage(first_stage, replacements, suffix)
|
||||
|
||||
# If the path was once again truncated, discard user replacements
|
||||
if not truncated:
|
||||
return second_stage, False
|
||||
|
||||
# If the path was truncated, discard user replacements
|
||||
# and run through one last legalization stage.
|
||||
if retruncated:
|
||||
second_stage_path, _ = _legalize_stage(
|
||||
first_stage_path, None, length, extension, fragment
|
||||
)
|
||||
|
||||
return second_stage_path, retruncated
|
||||
return _legalize_stage(first_stage, None, suffix)[0], True
|
||||
|
||||
|
||||
def str2bool(value: str) -> bool:
|
||||
|
|
@ -814,7 +809,7 @@ def as_string(value: Any) -> str:
|
|||
return str(value)
|
||||
|
||||
|
||||
def plurality(objs: Sequence[T]) -> tuple[T, int]:
|
||||
def plurality(objs: Iterable[T]) -> tuple[T, int]:
|
||||
"""Given a sequence of hashble objects, returns the object that
|
||||
is most common in the set and the its number of appearance. The
|
||||
sequence must contain at least one object.
|
||||
|
|
@ -825,13 +820,54 @@ def plurality(objs: Sequence[T]) -> tuple[T, int]:
|
|||
return c.most_common(1)[0]
|
||||
|
||||
|
||||
def get_most_common_tags(
|
||||
items: Sequence[Item],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract the likely current metadata for an album given a list of its
|
||||
items. Return two dictionaries:
|
||||
- The most common value for each field.
|
||||
- Whether each field's value was unanimous (values are booleans).
|
||||
"""
|
||||
assert items # Must be nonempty.
|
||||
|
||||
likelies = {}
|
||||
consensus = {}
|
||||
fields = [
|
||||
"artist",
|
||||
"album",
|
||||
"albumartist",
|
||||
"year",
|
||||
"disctotal",
|
||||
"mb_albumid",
|
||||
"label",
|
||||
"barcode",
|
||||
"catalognum",
|
||||
"country",
|
||||
"media",
|
||||
"albumdisambig",
|
||||
"data_source",
|
||||
]
|
||||
for field in fields:
|
||||
values = [item.get(field) for item in items if item]
|
||||
likelies[field], freq = plurality(values)
|
||||
consensus[field] = freq == len(values)
|
||||
|
||||
# If there's an album artist consensus, use this for the artist.
|
||||
if consensus["albumartist"] and likelies["albumartist"]:
|
||||
likelies["artist"] = likelies["albumartist"]
|
||||
|
||||
return likelies, consensus
|
||||
|
||||
|
||||
# stdout and stderr as bytes
|
||||
class CommandOutput(NamedTuple):
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
|
||||
|
||||
def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
|
||||
def command_output(
|
||||
cmd: list[str] | list[bytes], shell: bool = False
|
||||
) -> CommandOutput:
|
||||
"""Runs the command and returns its output after it has exited.
|
||||
|
||||
Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain
|
||||
|
|
@ -849,8 +885,6 @@ def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
|
|||
This replaces `subprocess.check_output` which can have problems if lots of
|
||||
output is sent to stderr.
|
||||
"""
|
||||
converted_cmd = [os.fsdecode(a) for a in cmd]
|
||||
|
||||
devnull = subprocess.DEVNULL
|
||||
|
||||
proc = subprocess.Popen(
|
||||
|
|
@ -865,22 +899,27 @@ def command_output(cmd: list[BytesOrStr], shell: bool = False) -> CommandOutput:
|
|||
if proc.returncode:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=proc.returncode,
|
||||
cmd=" ".join(converted_cmd),
|
||||
cmd=" ".join(map(os.fsdecode, cmd)),
|
||||
output=stdout + stderr,
|
||||
)
|
||||
return CommandOutput(stdout, stderr)
|
||||
|
||||
|
||||
def max_filename_length(path: BytesOrStr, limit=MAX_FILENAME_LENGTH) -> int:
|
||||
@cache
|
||||
def get_max_filename_length() -> int:
|
||||
"""Attempt to determine the maximum filename length for the
|
||||
filesystem containing `path`. If the value is greater than `limit`,
|
||||
then `limit` is used instead (to prevent errors when a filesystem
|
||||
misreports its capacity). If it cannot be determined (e.g., on
|
||||
Windows), return `limit`.
|
||||
"""
|
||||
if length := beets.config["max_filename_length"].get(int):
|
||||
return length
|
||||
|
||||
limit = MAX_FILENAME_LENGTH
|
||||
if hasattr(os, "statvfs"):
|
||||
try:
|
||||
res = os.statvfs(path)
|
||||
res = os.statvfs(beets.config["directory"].as_str())
|
||||
except OSError:
|
||||
return limit
|
||||
return min(res[9], limit)
|
||||
|
|
@ -985,19 +1024,6 @@ def case_sensitive(path: bytes) -> bool:
|
|||
return not os.path.samefile(lower_sys, upper_sys)
|
||||
|
||||
|
||||
def raw_seconds_short(string: str) -> float:
|
||||
"""Formats a human-readable M:SS string as a float (number of seconds).
|
||||
|
||||
Raises ValueError if the conversion cannot take place due to `string` not
|
||||
being in the right format.
|
||||
"""
|
||||
match = re.match(r"^(\d+):([0-5]\d)$", string)
|
||||
if not match:
|
||||
raise ValueError("String not in M:SS format")
|
||||
minutes, seconds = map(int, match.groups())
|
||||
return float(minutes * 60 + seconds)
|
||||
|
||||
|
||||
def asciify_path(path: str, sep_replace: str) -> str:
|
||||
"""Decodes all unicode characters in a path into ASCII equivalents.
|
||||
|
||||
|
|
@ -1035,21 +1061,94 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
|
|||
pool.join()
|
||||
|
||||
|
||||
class cached_classproperty:
|
||||
"""A decorator implementing a read-only property that is *lazy* in
|
||||
the sense that the getter is only invoked once. Subsequent accesses
|
||||
through *any* instance use the cached result.
|
||||
class cached_classproperty(Generic[T]):
|
||||
"""Descriptor implementing cached class properties.
|
||||
|
||||
Provides class-level dynamic property behavior where the getter function is
|
||||
called once per class and the result is cached for subsequent access. Unlike
|
||||
instance properties, this operates on the class rather than instances.
|
||||
"""
|
||||
|
||||
def __init__(self, getter):
|
||||
self.getter = getter
|
||||
self.cache = {}
|
||||
cache: ClassVar[dict[tuple[type[object], str], object]] = {}
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if owner not in self.cache:
|
||||
self.cache[owner] = self.getter(owner)
|
||||
name: str = ""
|
||||
|
||||
return self.cache[owner]
|
||||
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
|
||||
# however, `mypy` is unable to see this as a **class** property, and thinks
|
||||
# that this callable receives an **instance** of the object, failing the
|
||||
# type check, for example:
|
||||
# >>> class Album:
|
||||
# >>> @cached_classproperty
|
||||
# >>> def foo(cls):
|
||||
# >>> reveal_type(cls) # mypy: revealed type is "Album"
|
||||
# >>> return cls.bar
|
||||
#
|
||||
# Argument 1 to "cached_classproperty" has incompatible type
|
||||
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
|
||||
#
|
||||
# Therefore, we just use `Any` here, which is not ideal, but works.
|
||||
def __init__(self, getter: Callable[..., T]) -> None:
|
||||
"""Initialize the descriptor with the property getter function."""
|
||||
self.getter: Callable[..., T] = getter
|
||||
|
||||
def __set_name__(self, owner: object, name: str) -> None:
|
||||
"""Capture the attribute name this descriptor is assigned to."""
|
||||
self.name = name
|
||||
|
||||
def __get__(self, instance: object, owner: type[object]) -> T:
|
||||
"""Compute and cache if needed, and return the property value."""
|
||||
key: tuple[type[object], str] = owner, self.name
|
||||
if key not in self.cache:
|
||||
self.cache[key] = self.getter(owner)
|
||||
|
||||
return cast(T, self.cache[key])
|
||||
|
||||
|
||||
class LazySharedInstance(Generic[T]):
|
||||
"""A descriptor that provides access to a lazily-created shared instance of
|
||||
the containing class, while calling the class constructor to construct a
|
||||
new object works as usual.
|
||||
|
||||
```
|
||||
ID: int = 0
|
||||
|
||||
class Foo:
|
||||
def __init__():
|
||||
global ID
|
||||
|
||||
self.id = ID
|
||||
ID += 1
|
||||
|
||||
def func(self):
|
||||
print(self.id)
|
||||
|
||||
shared: LazySharedInstance[Foo] = LazySharedInstance()
|
||||
|
||||
a0 = Foo()
|
||||
a1 = Foo.shared
|
||||
a2 = Foo()
|
||||
a3 = Foo.shared
|
||||
|
||||
a0.func() # 0
|
||||
a1.func() # 1
|
||||
a2.func() # 2
|
||||
a3.func() # 1
|
||||
```
|
||||
"""
|
||||
|
||||
_instance: T | None = None
|
||||
|
||||
def __get__(self, instance: T | None, owner: type[T]) -> T:
|
||||
if instance is not None:
|
||||
raise RuntimeError(
|
||||
"shared instances must be obtained from the class property, "
|
||||
"not an instance"
|
||||
)
|
||||
|
||||
if self._instance is None:
|
||||
self._instance = owner()
|
||||
|
||||
return self._instance
|
||||
|
||||
|
||||
def get_module_tempdir(module: str) -> Path:
|
||||
|
|
|
|||
|
|
@ -16,23 +16,36 @@
|
|||
public resizing proxy if neither is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from itertools import chain
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from beets import logging, util
|
||||
from beets.util import displayable_path, get_temp_filename, syspath
|
||||
from beets.util import (
|
||||
LazySharedInstance,
|
||||
displayable_path,
|
||||
get_temp_filename,
|
||||
syspath,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
PROXY_URL = "https://images.weserv.nl/"
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
def resize_url(url, maxwidth, quality=0):
|
||||
def resize_url(url: str, maxwidth: int, quality: int = 0) -> str:
|
||||
"""Return a proxied image URL that resizes the original image to
|
||||
maxwidth (preserving aspect ratio).
|
||||
"""
|
||||
|
|
@ -44,25 +57,125 @@ def resize_url(url, maxwidth, quality=0):
|
|||
if quality > 0:
|
||||
params["q"] = quality
|
||||
|
||||
return "{}?{}".format(PROXY_URL, urlencode(params))
|
||||
return f"{PROXY_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
class LocalBackendNotAvailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_NOT_AVAILABLE = object()
|
||||
# Singleton pattern that the typechecker understands:
|
||||
# https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions
|
||||
class NotAvailable(Enum):
|
||||
token = 0
|
||||
|
||||
|
||||
class LocalBackend:
|
||||
_NOT_AVAILABLE = NotAvailable.token
|
||||
|
||||
|
||||
class LocalBackend(ABC):
|
||||
NAME: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def available(cls):
|
||||
@abstractmethod
|
||||
def version(cls) -> Any:
|
||||
"""Return the backend version if its dependencies are satisfied or
|
||||
raise `LocalBackendNotAvailableError`.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def available(cls) -> bool:
|
||||
"""Return `True` this backend's dependencies are satisfied and it can
|
||||
be used, `False` otherwise."""
|
||||
try:
|
||||
cls.version()
|
||||
return True
|
||||
except LocalBackendNotAvailableError:
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def resize(
|
||||
self,
|
||||
maxwidth: int,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
quality: int = 0,
|
||||
max_filesize: int = 0,
|
||||
) -> bytes:
|
||||
"""Resize an image to the given width and return the output path.
|
||||
|
||||
On error, logs a warning and returns `path_in`.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
|
||||
"""Return the (width, height) of the image or None if unavailable."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def deinterlace(
|
||||
self,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""Remove interlacing from an image and return the output path.
|
||||
|
||||
On error, logs a warning and returns `path_in`.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_format(self, path_in: bytes) -> str | None:
|
||||
"""Return the image format (e.g., 'PNG') or None if undetectable."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def convert_format(
|
||||
self,
|
||||
source: bytes,
|
||||
target: bytes,
|
||||
deinterlaced: bool,
|
||||
) -> bytes:
|
||||
"""Convert an image to a new format and return the new file path.
|
||||
|
||||
On error, logs a warning and returns `source`.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def can_compare(self) -> bool:
|
||||
"""Indicate whether image comparison is supported by this backend."""
|
||||
return False
|
||||
|
||||
def compare(
|
||||
self,
|
||||
im1: bytes,
|
||||
im2: bytes,
|
||||
compare_threshold: float,
|
||||
) -> bool | None:
|
||||
"""Compare two images and return `True` if they are similar enough, or
|
||||
`None` if there is an error.
|
||||
|
||||
This must only be called if `self.can_compare()` returns `True`.
|
||||
"""
|
||||
# It is an error to call this when ArtResizer.can_compare is not True.
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def can_write_metadata(self) -> bool:
|
||||
"""Indicate whether writing metadata to images is supported."""
|
||||
return False
|
||||
|
||||
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
|
||||
"""Write key-value metadata into the image file.
|
||||
|
||||
This must only be called if `self.can_write_metadata()` returns `True`.
|
||||
"""
|
||||
# It is an error to call this when ArtResizer.can_write_metadata is not True.
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class IMBackend(LocalBackend):
|
||||
NAME = "ImageMagick"
|
||||
|
|
@ -70,11 +183,11 @@ class IMBackend(LocalBackend):
|
|||
# These fields are used as a cache for `version()`. `_legacy` indicates
|
||||
# whether the modern `magick` binary is available or whether to fall back
|
||||
# to the old-style `convert`, `identify`, etc. commands.
|
||||
_version = None
|
||||
_legacy = None
|
||||
_version: tuple[int, int, int] | NotAvailable | None = None
|
||||
_legacy: bool | None = None
|
||||
|
||||
@classmethod
|
||||
def version(cls):
|
||||
def version(cls) -> tuple[int, int, int]:
|
||||
"""Obtain and cache ImageMagick version.
|
||||
|
||||
Raises `LocalBackendNotAvailableError` if not available.
|
||||
|
|
@ -98,12 +211,17 @@ class IMBackend(LocalBackend):
|
|||
)
|
||||
cls._legacy = legacy
|
||||
|
||||
if cls._version is _NOT_AVAILABLE:
|
||||
# cls._version is never None here, but mypy doesn't get that
|
||||
if cls._version is _NOT_AVAILABLE or cls._version is None:
|
||||
raise LocalBackendNotAvailableError()
|
||||
else:
|
||||
return cls._version
|
||||
|
||||
def __init__(self):
|
||||
convert_cmd: list[str]
|
||||
identify_cmd: list[str]
|
||||
compare_cmd: list[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a wrapper around ImageMagick for local image operations.
|
||||
|
||||
Stores the ImageMagick version and legacy flag. If ImageMagick is not
|
||||
|
|
@ -124,8 +242,13 @@ class IMBackend(LocalBackend):
|
|||
self.compare_cmd = ["magick", "compare"]
|
||||
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
self,
|
||||
maxwidth: int,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
quality: int = 0,
|
||||
max_filesize: int = 0,
|
||||
) -> bytes:
|
||||
"""Resize using ImageMagick.
|
||||
|
||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||
|
|
@ -135,7 +258,7 @@ class IMBackend(LocalBackend):
|
|||
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
|
||||
|
||||
log.debug(
|
||||
"artresizer: ImageMagick resizing {0} to {1}",
|
||||
"artresizer: ImageMagick resizing {} to {}",
|
||||
displayable_path(path_in),
|
||||
displayable_path(path_out),
|
||||
)
|
||||
|
|
@ -145,7 +268,7 @@ class IMBackend(LocalBackend):
|
|||
# with regards to the height.
|
||||
# ImageMagick already seems to default to no interlace, but we include
|
||||
# it here for the sake of explicitness.
|
||||
cmd = self.convert_cmd + [
|
||||
cmd: list[str] = self.convert_cmd + [
|
||||
syspath(path_in, prefix=False),
|
||||
"-resize",
|
||||
f"{maxwidth}x>",
|
||||
|
|
@ -167,15 +290,15 @@ class IMBackend(LocalBackend):
|
|||
util.command_output(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
log.warning(
|
||||
"artresizer: IM convert failed for {0}",
|
||||
"artresizer: IM convert failed for {}",
|
||||
displayable_path(path_in),
|
||||
)
|
||||
return path_in
|
||||
|
||||
return path_out
|
||||
|
||||
def get_size(self, path_in):
|
||||
cmd = self.identify_cmd + [
|
||||
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
|
||||
cmd: list[str] = self.identify_cmd + [
|
||||
"-format",
|
||||
"%w %h",
|
||||
syspath(path_in, prefix=False),
|
||||
|
|
@ -186,20 +309,30 @@ class IMBackend(LocalBackend):
|
|||
except subprocess.CalledProcessError as exc:
|
||||
log.warning("ImageMagick size query failed")
|
||||
log.debug(
|
||||
"`convert` exited with (status {}) when "
|
||||
"`convert` exited with (status {.returncode}) when "
|
||||
"getting size with command {}:\n{}",
|
||||
exc.returncode,
|
||||
exc,
|
||||
cmd,
|
||||
exc.output.strip(),
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return tuple(map(int, out.split(b" ")))
|
||||
size = tuple(map(int, out.split(b" ")))
|
||||
except IndexError:
|
||||
log.warning("Could not understand IM output: {0!r}", out)
|
||||
return None
|
||||
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
if len(size) != 2:
|
||||
log.warning("Could not understand IM output: {0!r}", out)
|
||||
return None
|
||||
|
||||
return size
|
||||
|
||||
def deinterlace(
|
||||
self,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
) -> bytes:
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
|
||||
|
||||
|
|
@ -217,16 +350,24 @@ class IMBackend(LocalBackend):
|
|||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def get_format(self, filepath):
|
||||
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
|
||||
def get_format(self, path_in: bytes) -> str | None:
|
||||
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(path_in)]
|
||||
|
||||
try:
|
||||
return util.command_output(cmd).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# Image formats should really only be ASCII strings such as "PNG",
|
||||
# if anything else is returned, something is off and we return
|
||||
# None for safety.
|
||||
return util.command_output(cmd).stdout.decode("ascii", "strict")
|
||||
except (subprocess.CalledProcessError, UnicodeError):
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
def convert_format(self, source, target, deinterlaced):
|
||||
def convert_format(
|
||||
self,
|
||||
source: bytes,
|
||||
target: bytes,
|
||||
deinterlaced: bool,
|
||||
) -> bytes:
|
||||
cmd = self.convert_cmd + [
|
||||
syspath(source),
|
||||
*(["-interlace", "none"] if deinterlaced else []),
|
||||
|
|
@ -243,10 +384,15 @@ class IMBackend(LocalBackend):
|
|||
return source
|
||||
|
||||
@property
|
||||
def can_compare(self):
|
||||
def can_compare(self) -> bool:
|
||||
return self.version() > (6, 8, 7)
|
||||
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
def compare(
|
||||
self,
|
||||
im1: bytes,
|
||||
im2: bytes,
|
||||
compare_threshold: float,
|
||||
) -> bool | None:
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
# Converting images to grayscale tends to minimize the weight
|
||||
|
|
@ -286,6 +432,10 @@ class IMBackend(LocalBackend):
|
|||
close_fds=not is_windows,
|
||||
)
|
||||
|
||||
# help out mypy
|
||||
assert convert_proc.stdout is not None
|
||||
assert convert_proc.stderr is not None
|
||||
|
||||
# Check the convert output. We're not interested in the
|
||||
# standard output; that gets piped to the next stage.
|
||||
convert_proc.stdout.close()
|
||||
|
|
@ -294,8 +444,8 @@ class IMBackend(LocalBackend):
|
|||
convert_proc.wait()
|
||||
if convert_proc.returncode:
|
||||
log.debug(
|
||||
"ImageMagick convert failed with status {}: {!r}",
|
||||
convert_proc.returncode,
|
||||
"ImageMagick convert failed with status {.returncode}: {!r}",
|
||||
convert_proc,
|
||||
convert_stderr,
|
||||
)
|
||||
return None
|
||||
|
|
@ -305,7 +455,7 @@ class IMBackend(LocalBackend):
|
|||
if compare_proc.returncode:
|
||||
if compare_proc.returncode != 1:
|
||||
log.debug(
|
||||
"ImageMagick compare failed: {0}, {1}",
|
||||
"ImageMagick compare failed: {}, {}",
|
||||
displayable_path(im2),
|
||||
displayable_path(im1),
|
||||
)
|
||||
|
|
@ -325,18 +475,19 @@ class IMBackend(LocalBackend):
|
|||
log.debug("IM output is not a number: {0!r}", out_str)
|
||||
return None
|
||||
|
||||
log.debug("ImageMagick compare score: {0}", phash_diff)
|
||||
log.debug("ImageMagick compare score: {}", phash_diff)
|
||||
return phash_diff <= compare_threshold
|
||||
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
def can_write_metadata(self) -> bool:
|
||||
return True
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
assignments = list(
|
||||
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
|
||||
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
|
||||
assignments = chain.from_iterable(
|
||||
("-set", k, v) for k, v in metadata.items()
|
||||
)
|
||||
command = self.convert_cmd + [file, *assignments, file]
|
||||
str_file = os.fsdecode(file)
|
||||
command = self.convert_cmd + [str_file, *assignments, str_file]
|
||||
|
||||
util.command_output(command)
|
||||
|
||||
|
|
@ -345,13 +496,13 @@ class PILBackend(LocalBackend):
|
|||
NAME = "PIL"
|
||||
|
||||
@classmethod
|
||||
def version(cls):
|
||||
def version(cls) -> None:
|
||||
try:
|
||||
__import__("PIL", fromlist=["Image"])
|
||||
except ImportError:
|
||||
raise LocalBackendNotAvailableError()
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a wrapper around PIL for local image operations.
|
||||
|
||||
If PIL is not available, raise an Exception.
|
||||
|
|
@ -359,8 +510,13 @@ class PILBackend(LocalBackend):
|
|||
self.version()
|
||||
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
self,
|
||||
maxwidth: int,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
quality: int = 0,
|
||||
max_filesize: int = 0,
|
||||
) -> bytes:
|
||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||
of resized image.
|
||||
"""
|
||||
|
|
@ -370,7 +526,7 @@ class PILBackend(LocalBackend):
|
|||
from PIL import Image
|
||||
|
||||
log.debug(
|
||||
"artresizer: PIL resizing {0} to {1}",
|
||||
"artresizer: PIL resizing {} to {}",
|
||||
displayable_path(path_in),
|
||||
displayable_path(path_out),
|
||||
)
|
||||
|
|
@ -399,7 +555,7 @@ class PILBackend(LocalBackend):
|
|||
for i in range(5):
|
||||
# 5 attempts is an arbitrary choice
|
||||
filesize = os.stat(syspath(path_out)).st_size
|
||||
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
||||
log.debug("PIL Pass {} : Output size: {}B", i, filesize)
|
||||
if filesize <= max_filesize:
|
||||
return path_out
|
||||
# The relationship between filesize & quality will be
|
||||
|
|
@ -416,7 +572,7 @@ class PILBackend(LocalBackend):
|
|||
progressive=False,
|
||||
)
|
||||
log.warning(
|
||||
"PIL Failed to resize file to below {0}B", max_filesize
|
||||
"PIL Failed to resize file to below {}B", max_filesize
|
||||
)
|
||||
return path_out
|
||||
|
||||
|
|
@ -424,12 +580,12 @@ class PILBackend(LocalBackend):
|
|||
return path_out
|
||||
except OSError:
|
||||
log.error(
|
||||
"PIL cannot create thumbnail for '{0}'",
|
||||
"PIL cannot create thumbnail for '{}'",
|
||||
displayable_path(path_in),
|
||||
)
|
||||
return path_in
|
||||
|
||||
def get_size(self, path_in):
|
||||
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
|
|
@ -441,7 +597,11 @@ class PILBackend(LocalBackend):
|
|||
)
|
||||
return None
|
||||
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
def deinterlace(
|
||||
self,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
) -> bytes:
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
|
||||
|
||||
|
|
@ -455,11 +615,11 @@ class PILBackend(LocalBackend):
|
|||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def get_format(self, filepath):
|
||||
def get_format(self, path_in: bytes) -> str | None:
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
with Image.open(syspath(filepath)) as im:
|
||||
with Image.open(syspath(path_in)) as im:
|
||||
return im.format
|
||||
except (
|
||||
ValueError,
|
||||
|
|
@ -467,10 +627,15 @@ class PILBackend(LocalBackend):
|
|||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
):
|
||||
log.exception("failed to detect image format for {}", filepath)
|
||||
log.exception("failed to detect image format for {}", path_in)
|
||||
return None
|
||||
|
||||
def convert_format(self, source, target, deinterlaced):
|
||||
def convert_format(
|
||||
self,
|
||||
source: bytes,
|
||||
target: bytes,
|
||||
deinterlaced: bool,
|
||||
) -> bytes:
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
|
|
@ -488,18 +653,23 @@ class PILBackend(LocalBackend):
|
|||
return source
|
||||
|
||||
@property
|
||||
def can_compare(self):
|
||||
def can_compare(self) -> bool:
|
||||
return False
|
||||
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
def compare(
|
||||
self,
|
||||
im1: bytes,
|
||||
im2: bytes,
|
||||
compare_threshold: float,
|
||||
) -> bool | None:
|
||||
# It is an error to call this when ArtResizer.can_compare is not True.
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
def can_write_metadata(self) -> bool:
|
||||
return True
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
# FIXME: Detect and handle other file types (currently, the only user
|
||||
|
|
@ -507,68 +677,68 @@ class PILBackend(LocalBackend):
|
|||
im = Image.open(syspath(file))
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
for k, v in metadata.items():
|
||||
meta.add_text(k, v, 0)
|
||||
meta.add_text(k, v, zip=False)
|
||||
im.save(os.fsdecode(file), "PNG", pnginfo=meta)
|
||||
|
||||
|
||||
class Shareable(type):
|
||||
"""A pseudo-singleton metaclass that allows both shared and
|
||||
non-shared instances. The ``MyClass.shared`` property holds a
|
||||
lazily-created shared instance of ``MyClass`` while calling
|
||||
``MyClass()`` to construct a new object works as usual.
|
||||
"""
|
||||
|
||||
def __init__(cls, name, bases, dict):
|
||||
super().__init__(name, bases, dict)
|
||||
cls._instance = None
|
||||
|
||||
@property
|
||||
def shared(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
BACKEND_CLASSES = [
|
||||
BACKEND_CLASSES: list[type[LocalBackend]] = [
|
||||
IMBackend,
|
||||
PILBackend,
|
||||
]
|
||||
|
||||
|
||||
class ArtResizer(metaclass=Shareable):
|
||||
"""A singleton class that performs image resizes."""
|
||||
class ArtResizer:
|
||||
"""A class that dispatches image operations to an available backend."""
|
||||
|
||||
def __init__(self):
|
||||
local_method: LocalBackend | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create a resizer object with an inferred method."""
|
||||
# Check if a local backend is available, and store an instance of the
|
||||
# backend class. Otherwise, fallback to the web proxy.
|
||||
for backend_cls in BACKEND_CLASSES:
|
||||
try:
|
||||
self.local_method = backend_cls()
|
||||
log.debug(f"artresizer: method is {self.local_method.NAME}")
|
||||
log.debug("artresizer: method is {.local_method.NAME}", self)
|
||||
break
|
||||
except LocalBackendNotAvailableError:
|
||||
continue
|
||||
else:
|
||||
# FIXME: Turn WEBPROXY into a backend class as well to remove all
|
||||
# the special casing. Then simply delegate all methods to the
|
||||
# backends. (How does proxy_url fit in here, however?)
|
||||
# Use an ABC (or maybe a typing Protocol?) for backend
|
||||
# methods, such that both individual backends as well as
|
||||
# ArtResizer implement it.
|
||||
# It should probably be configurable which backends classes to
|
||||
# consider, similar to fetchart or lyrics backends (i.e. a list
|
||||
# of backends sorted by priority).
|
||||
log.debug("artresizer: method is WEBPROXY")
|
||||
self.local_method = None
|
||||
|
||||
shared: LazySharedInstance[ArtResizer] = LazySharedInstance()
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
if self.local:
|
||||
def method(self) -> str:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.NAME
|
||||
else:
|
||||
return "WEBPROXY"
|
||||
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
self,
|
||||
maxwidth: int,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
quality: int = 0,
|
||||
max_filesize: int = 0,
|
||||
) -> bytes:
|
||||
"""Manipulate an image file according to the method, returning a
|
||||
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
|
||||
temporary file and encodes with the specified quality level.
|
||||
For WEBPROXY, returns `path_in` unmodified.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.resize(
|
||||
maxwidth,
|
||||
path_in,
|
||||
|
|
@ -580,18 +750,22 @@ class ArtResizer(metaclass=Shareable):
|
|||
# Handled by `proxy_url` already.
|
||||
return path_in
|
||||
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
def deinterlace(
|
||||
self,
|
||||
path_in: bytes,
|
||||
path_out: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""Deinterlace an image.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.deinterlace(path_in, path_out)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def proxy_url(self, maxwidth, url, quality=0):
|
||||
def proxy_url(self, maxwidth: int, url: str, quality: int = 0) -> str:
|
||||
"""Modifies an image URL according the method, returning a new
|
||||
URL. For WEBPROXY, a URL on the proxy server is returned.
|
||||
Otherwise, the URL is returned unmodified.
|
||||
|
|
@ -603,42 +777,48 @@ class ArtResizer(metaclass=Shareable):
|
|||
return resize_url(url, maxwidth, quality)
|
||||
|
||||
@property
|
||||
def local(self):
|
||||
def local(self) -> bool:
|
||||
"""A boolean indicating whether the resizing method is performed
|
||||
locally (i.e., PIL or ImageMagick).
|
||||
"""
|
||||
return self.local_method is not None
|
||||
|
||||
def get_size(self, path_in):
|
||||
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
|
||||
"""Return the size of an image file as an int couple (width, height)
|
||||
in pixels.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.get_size(path_in)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
raise RuntimeError(
|
||||
"image cannot be obtained without artresizer backend"
|
||||
)
|
||||
|
||||
def get_format(self, path_in):
|
||||
def get_format(self, path_in: bytes) -> str | None:
|
||||
"""Returns the format of the image as a string.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.get_format(path_in)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
def reformat(self, path_in, new_format, deinterlaced=True):
|
||||
def reformat(
|
||||
self,
|
||||
path_in: bytes,
|
||||
new_format: str,
|
||||
deinterlaced: bool = True,
|
||||
) -> bytes:
|
||||
"""Converts image to desired format, updating its extension, but
|
||||
keeping the same filename.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if not self.local:
|
||||
if self.local_method is None:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
|
|
@ -664,40 +844,45 @@ class ArtResizer(metaclass=Shareable):
|
|||
return result_path
|
||||
|
||||
@property
|
||||
def can_compare(self):
|
||||
def can_compare(self) -> bool:
|
||||
"""A boolean indicating whether image comparison is available"""
|
||||
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.can_compare
|
||||
else:
|
||||
return False
|
||||
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
def compare(
|
||||
self,
|
||||
im1: bytes,
|
||||
im2: bytes,
|
||||
compare_threshold: float,
|
||||
) -> bool | None:
|
||||
"""Return a boolean indicating whether two images are similar.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.compare(im1, im2, compare_threshold)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
def can_write_metadata(self) -> bool:
|
||||
"""A boolean indicating whether writing image metadata is supported."""
|
||||
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
return self.local_method.can_write_metadata
|
||||
else:
|
||||
return False
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
|
||||
"""Write key-value metadata to the image file.
|
||||
|
||||
Only available locally. Currently, expects the image to be a PNG file.
|
||||
"""
|
||||
if self.local:
|
||||
if self.local_method is not None:
|
||||
self.local_method.write_metadata(file, metadata)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ def spawn(coro):
|
|||
and child coroutines run concurrently.
|
||||
"""
|
||||
if not isinstance(coro, types.GeneratorType):
|
||||
raise ValueError("%s is not a coroutine" % coro)
|
||||
raise ValueError(f"{coro} is not a coroutine")
|
||||
return SpawnEvent(coro)
|
||||
|
||||
|
||||
|
|
@ -569,7 +569,7 @@ def call(coro):
|
|||
returns a value using end(), then this event returns that value.
|
||||
"""
|
||||
if not isinstance(coro, types.GeneratorType):
|
||||
raise ValueError("%s is not a coroutine" % coro)
|
||||
raise ValueError(f"{coro} is not a coroutine")
|
||||
return DelegationEvent(coro)
|
||||
|
||||
|
||||
|
|
|
|||
66
beets/util/config.py
Normal file
66
beets/util/config.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection, Sequence
|
||||
|
||||
|
||||
def sanitize_choices(
|
||||
choices: Sequence[str], choices_all: Collection[str]
|
||||
) -> list[str]:
|
||||
"""Clean up a stringlist configuration attribute: keep only choices
|
||||
elements present in choices_all, remove duplicate elements, expand '*'
|
||||
wildcard while keeping original stringlist order.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
others = [x for x in choices_all if x not in choices]
|
||||
res: list[str] = []
|
||||
for s in choices:
|
||||
if s not in seen:
|
||||
if s in list(choices_all):
|
||||
res.append(s)
|
||||
elif s == "*":
|
||||
res.extend(others)
|
||||
seen.add(s)
|
||||
return res
|
||||
|
||||
|
||||
def sanitize_pairs(
|
||||
pairs: Sequence[tuple[str, str]], pairs_all: Sequence[tuple[str, str]]
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Clean up a single-element mapping configuration attribute as returned
|
||||
by Confuse's `Pairs` template: keep only two-element tuples present in
|
||||
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
|
||||
wildcards while keeping the original order. Note that ('*', '*') and
|
||||
('*', 'whatever') have the same effect.
|
||||
|
||||
For example,
|
||||
|
||||
>>> sanitize_pairs(
|
||||
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
|
||||
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
|
||||
... ('key', 'value')]
|
||||
... )
|
||||
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
|
||||
"""
|
||||
pairs_all = list(pairs_all)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
others = [x for x in pairs_all if x not in pairs]
|
||||
res: list[tuple[str, str]] = []
|
||||
for k, values in pairs:
|
||||
for v in values.split():
|
||||
x = (k, v)
|
||||
if x in pairs_all:
|
||||
if x not in seen:
|
||||
seen.add(x)
|
||||
res.append(x)
|
||||
elif k == "*":
|
||||
new = [o for o in others if o not in seen]
|
||||
seen.update(new)
|
||||
res.extend(new)
|
||||
elif v == "*":
|
||||
new = [o for o in others if o not in seen and o[0] == k]
|
||||
seen.update(new)
|
||||
res.extend(new)
|
||||
return res
|
||||
60
beets/util/deprecation.py
Normal file
60
beets/util/deprecation.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
import beets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logging import Logger
|
||||
|
||||
|
||||
def _format_message(old: str, new: str | None = None) -> str:
|
||||
next_major = f"{Version(beets.__version__).major + 1}.0.0"
|
||||
msg = f"{old} is deprecated and will be removed in version {next_major}."
|
||||
if new:
|
||||
msg += f" Use {new} instead."
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def deprecate_for_user(
|
||||
logger: Logger, old: str, new: str | None = None
|
||||
) -> None:
|
||||
logger.warning(_format_message(old, new))
|
||||
|
||||
|
||||
def deprecate_for_maintainers(
|
||||
old: str, new: str | None = None, stacklevel: int = 1
|
||||
) -> None:
|
||||
"""Issue a deprecation warning visible to maintainers during development.
|
||||
|
||||
Emits a DeprecationWarning that alerts developers about deprecated code
|
||||
patterns. Unlike user-facing warnings, these are primarily for internal
|
||||
code maintenance and appear during test runs or with warnings enabled.
|
||||
"""
|
||||
warnings.warn(
|
||||
_format_message(old, new), DeprecationWarning, stacklevel=stacklevel + 1
|
||||
)
|
||||
|
||||
|
||||
def deprecate_imports(
|
||||
old_module: str, new_module_by_name: dict[str, str], name: str
|
||||
) -> Any:
|
||||
"""Handle deprecated module imports by redirecting to new locations.
|
||||
|
||||
Facilitates gradual migration of module structure by intercepting import
|
||||
attempts for relocated functionality. Issues deprecation warnings while
|
||||
transparently providing access to the moved implementation, allowing
|
||||
existing code to continue working during transition periods.
|
||||
"""
|
||||
if new_module := new_module_by_name.get(name):
|
||||
deprecate_for_maintainers(
|
||||
f"'{old_module}.{name}'", f"'{new_module}.{name}'", stacklevel=2
|
||||
)
|
||||
|
||||
return getattr(import_module(new_module), name)
|
||||
raise AttributeError(f"module '{old_module}' has no attribute '{name}'")
|
||||
|
|
@ -105,8 +105,6 @@ def compile_func(arg_names, statements, name="_the_func", debug=False):
|
|||
decorator_list=[],
|
||||
)
|
||||
|
||||
# The ast.Module signature changed in 3.8 to accept a list of types to
|
||||
# ignore.
|
||||
mod = ast.Module([func_def], [])
|
||||
|
||||
ast.fix_missing_locations(mod)
|
||||
|
|
@ -136,7 +134,7 @@ class Symbol:
|
|||
self.original = original
|
||||
|
||||
def __repr__(self):
|
||||
return "Symbol(%s)" % repr(self.ident)
|
||||
return f"Symbol({self.ident!r})"
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the symbol in the environment, returning a Unicode
|
||||
|
|
@ -152,7 +150,7 @@ class Symbol:
|
|||
def translate(self):
|
||||
"""Compile the variable lookup."""
|
||||
ident = self.ident
|
||||
expr = ex_rvalue(VARIABLE_PREFIX + ident)
|
||||
expr = ex_rvalue(f"{VARIABLE_PREFIX}{ident}")
|
||||
return [expr], {ident}, set()
|
||||
|
||||
|
||||
|
|
@ -165,9 +163,7 @@ class Call:
|
|||
self.original = original
|
||||
|
||||
def __repr__(self):
|
||||
return "Call({}, {}, {})".format(
|
||||
repr(self.ident), repr(self.args), repr(self.original)
|
||||
)
|
||||
return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the function call in the environment, returning a
|
||||
|
|
@ -180,7 +176,7 @@ class Call:
|
|||
except Exception as exc:
|
||||
# Function raised exception! Maybe inlining the name of
|
||||
# the exception will help debug.
|
||||
return "<%s>" % str(exc)
|
||||
return f"<{exc}>"
|
||||
return str(out)
|
||||
else:
|
||||
return self.original
|
||||
|
|
@ -213,7 +209,7 @@ class Call:
|
|||
)
|
||||
)
|
||||
|
||||
subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
|
||||
subexpr_call = ex_call(f"{FUNCTION_PREFIX}{self.ident}", arg_exprs)
|
||||
return [subexpr_call], varnames, funcnames
|
||||
|
||||
|
||||
|
|
@ -226,7 +222,7 @@ class Expression:
|
|||
self.parts = parts
|
||||
|
||||
def __repr__(self):
|
||||
return "Expression(%s)" % (repr(self.parts))
|
||||
return f"Expression({self.parts!r})"
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the entire expression in the environment, returning
|
||||
|
|
@ -298,9 +294,6 @@ class Parser:
|
|||
GROUP_CLOSE,
|
||||
ESCAPE_CHAR,
|
||||
)
|
||||
special_char_re = re.compile(
|
||||
r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
|
||||
)
|
||||
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
|
||||
terminator_chars = (GROUP_CLOSE,)
|
||||
|
||||
|
|
@ -312,24 +305,18 @@ class Parser:
|
|||
"""
|
||||
# Append comma (ARG_SEP) to the list of special characters only when
|
||||
# parsing function arguments.
|
||||
extra_special_chars = ()
|
||||
special_char_re = self.special_char_re
|
||||
if self.in_argument:
|
||||
extra_special_chars = (ARG_SEP,)
|
||||
special_char_re = re.compile(
|
||||
r"[%s]|\Z"
|
||||
% "".join(
|
||||
re.escape(c)
|
||||
for c in self.special_chars + extra_special_chars
|
||||
)
|
||||
)
|
||||
extra_special_chars = (ARG_SEP,) if self.in_argument else ()
|
||||
special_chars = (*self.special_chars, *extra_special_chars)
|
||||
special_char_re = re.compile(
|
||||
rf"[{''.join(map(re.escape, special_chars))}]|\Z"
|
||||
)
|
||||
|
||||
text_parts = []
|
||||
|
||||
while self.pos < len(self.string):
|
||||
char = self.string[self.pos]
|
||||
|
||||
if char not in self.special_chars + extra_special_chars:
|
||||
if char not in special_chars:
|
||||
# A non-special character. Skip to the next special
|
||||
# character, treating the interstice as literal text.
|
||||
next_pos = (
|
||||
|
|
@ -566,9 +553,9 @@ class Template:
|
|||
|
||||
argnames = []
|
||||
for varname in varnames:
|
||||
argnames.append(VARIABLE_PREFIX + varname)
|
||||
argnames.append(f"{VARIABLE_PREFIX}{varname}")
|
||||
for funcname in funcnames:
|
||||
argnames.append(FUNCTION_PREFIX + funcname)
|
||||
argnames.append(f"{FUNCTION_PREFIX}{funcname}")
|
||||
|
||||
func = compile_func(
|
||||
argnames,
|
||||
|
|
@ -578,9 +565,9 @@ class Template:
|
|||
def wrapper_func(values={}, functions={}):
|
||||
args = {}
|
||||
for varname in varnames:
|
||||
args[VARIABLE_PREFIX + varname] = values[varname]
|
||||
args[f"{VARIABLE_PREFIX}{varname}"] = values[varname]
|
||||
for funcname in funcnames:
|
||||
args[FUNCTION_PREFIX + funcname] = functions[funcname]
|
||||
args[f"{FUNCTION_PREFIX}{funcname}"] = functions[funcname]
|
||||
parts = func(**args)
|
||||
return "".join(parts)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,9 @@ import os
|
|||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
|
||||
def is_hidden(path: Union[bytes, Path]) -> bool:
|
||||
def is_hidden(path: bytes | Path) -> bool:
|
||||
"""
|
||||
Determine whether the given path is treated as a 'hidden file' by the OS.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -14,36 +14,20 @@
|
|||
|
||||
"""Helpers around the extraction of album/track ID's from metadata sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Spotify IDs consist of 22 alphanumeric characters
|
||||
# (zero-left-padded base62 representation of randomly generated UUID4)
|
||||
spotify_id_regex = {
|
||||
"pattern": r"(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})",
|
||||
"match_group": 2,
|
||||
}
|
||||
from beets import logging
|
||||
|
||||
deezer_id_regex = {
|
||||
"pattern": r"(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)",
|
||||
"match_group": 4,
|
||||
}
|
||||
|
||||
beatport_id_regex = {
|
||||
"pattern": r"(^|beatport\.com/release/.+/)(\d+)$",
|
||||
"match_group": 2,
|
||||
}
|
||||
|
||||
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
|
||||
# the URL can be used as the identifier. The Bandcamp metadata source plugin
|
||||
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
|
||||
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
def extract_discogs_id_regex(album_id):
|
||||
"""Returns the Discogs_id or None."""
|
||||
# Discogs-IDs are simple integers. In order to avoid confusion with
|
||||
# other metadata plugins, we only look for very specific formats of the
|
||||
# input string:
|
||||
PATTERN_BY_SOURCE = {
|
||||
"spotify": re.compile(r"(?:^|open\.spotify\.com/[^/]+/)([0-9A-Za-z]{22})"),
|
||||
"deezer": re.compile(r"(?:^|deezer\.com/)(?:[a-z]*/)?(?:[^/]+/)?(\d+)"),
|
||||
"beatport": re.compile(r"(?:^|beatport\.com/release/.+/)(\d+)$"),
|
||||
"musicbrainz": re.compile(r"(\w{8}(?:-\w{4}){3}-\w{12})"),
|
||||
# - plain integer, optionally wrapped in brackets and prefixed by an
|
||||
# 'r', as this is how discogs displays the release ID on its webpage.
|
||||
# - legacy url format: discogs.com/<name of release>/release/<id>
|
||||
|
|
@ -51,15 +35,35 @@ def extract_discogs_id_regex(album_id):
|
|||
# - current url format: discogs.com/release/<id>-<name of release>
|
||||
# See #291, #4080 and #4085 for the discussions leading up to these
|
||||
# patterns.
|
||||
# Regex has been tested here https://regex101.com/r/TOu7kw/1
|
||||
"discogs": re.compile(
|
||||
r"(?:^|\[?r|discogs\.com/(?:[^/]+/)?release/)(\d+)\b"
|
||||
),
|
||||
# There is no such thing as a Bandcamp album or artist ID, the URL can be
|
||||
# used as the identifier. The Bandcamp metadata source plugin works that way
|
||||
# - https://github.com/snejus/beetcamp. Bandcamp album URLs usually look
|
||||
# like: https://nameofartist.bandcamp.com/album/nameofalbum
|
||||
"bandcamp": re.compile(r"(.+)"),
|
||||
"tidal": re.compile(r"([^/]+)$"),
|
||||
}
|
||||
|
||||
for pattern in [
|
||||
r"^\[?r?(?P<id>\d+)\]?$",
|
||||
r"discogs\.com/release/(?P<id>\d+)-?",
|
||||
r"discogs\.com/[^/]+/release/(?P<id>\d+)",
|
||||
]:
|
||||
match = re.search(pattern, album_id)
|
||||
if match:
|
||||
return int(match.group("id"))
|
||||
|
||||
def extract_release_id(source: str, id_: str) -> str | None:
|
||||
"""Extract the release ID from a given source and ID.
|
||||
|
||||
Normally, the `id_` is a url string which contains the ID of the
|
||||
release. This function extracts the ID from the URL based on the
|
||||
`source` provided.
|
||||
"""
|
||||
try:
|
||||
source_pattern = PATTERN_BY_SOURCE[source.lower()]
|
||||
except KeyError:
|
||||
log.debug(
|
||||
"Unknown source '{}' for ID extraction. Returning id/url as-is.",
|
||||
source,
|
||||
)
|
||||
return id_
|
||||
|
||||
if m := source_pattern.search(str(id_)):
|
||||
return m[1]
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -36,18 +36,20 @@ from __future__ import annotations
|
|||
import queue
|
||||
import sys
|
||||
from threading import Lock, Thread
|
||||
from typing import Callable, Generator, TypeVar
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypeVarTuple, Unpack
|
||||
else:
|
||||
from typing_extensions import TypeVarTuple, Unpack
|
||||
from typing_extensions import TypeVarTuple, Unpack
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
|
||||
BUBBLE = "__PIPELINE_BUBBLE__"
|
||||
POISON = "__PIPELINE_POISON__"
|
||||
|
||||
DEFAULT_QUEUE_SIZE = 16
|
||||
|
||||
Tq = TypeVar("Tq")
|
||||
|
||||
|
||||
def _invalidate_queue(q, val=None, sync=True):
|
||||
"""Breaks a Queue such that it never blocks, always has size 1,
|
||||
|
|
@ -91,7 +93,7 @@ def _invalidate_queue(q, val=None, sync=True):
|
|||
q.mutex.release()
|
||||
|
||||
|
||||
class CountedQueue(queue.Queue):
|
||||
class CountedQueue(queue.Queue[Tq]):
|
||||
"""A queue that keeps track of the number of threads that are
|
||||
still feeding into it. The queue is poisoned when all threads are
|
||||
finished with the queue.
|
||||
|
|
@ -492,64 +494,3 @@ class Pipeline:
|
|||
msgs = next_msgs
|
||||
for msg in msgs:
|
||||
yield msg
|
||||
|
||||
|
||||
# Smoke test.
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
# Test a normally-terminating pipeline both in sequence and
|
||||
# in parallel.
|
||||
def produce():
|
||||
for i in range(5):
|
||||
print("generating %i" % i)
|
||||
time.sleep(1)
|
||||
yield i
|
||||
|
||||
def work():
|
||||
num = yield
|
||||
while True:
|
||||
print("processing %i" % num)
|
||||
time.sleep(2)
|
||||
num = yield num * 2
|
||||
|
||||
def consume():
|
||||
while True:
|
||||
num = yield
|
||||
time.sleep(1)
|
||||
print("received %i" % num)
|
||||
|
||||
ts_start = time.time()
|
||||
Pipeline([produce(), work(), consume()]).run_sequential()
|
||||
ts_seq = time.time()
|
||||
Pipeline([produce(), work(), consume()]).run_parallel()
|
||||
ts_par = time.time()
|
||||
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
|
||||
ts_end = time.time()
|
||||
print("Sequential time:", ts_seq - ts_start)
|
||||
print("Parallel time:", ts_par - ts_seq)
|
||||
print("Multiply-parallel time:", ts_end - ts_par)
|
||||
print()
|
||||
|
||||
# Test a pipeline that raises an exception.
|
||||
def exc_produce():
|
||||
for i in range(10):
|
||||
print("generating %i" % i)
|
||||
time.sleep(1)
|
||||
yield i
|
||||
|
||||
def exc_work():
|
||||
num = yield
|
||||
while True:
|
||||
print("processing %i" % num)
|
||||
time.sleep(3)
|
||||
if num == 3:
|
||||
raise Exception()
|
||||
num = yield num * 2
|
||||
|
||||
def exc_consume():
|
||||
while True:
|
||||
num = yield
|
||||
print("received %i" % num)
|
||||
|
||||
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
||||
|
|
|
|||
61
beets/util/units.py
Normal file
61
beets/util/units.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import re
|
||||
|
||||
|
||||
def raw_seconds_short(string: str) -> float:
|
||||
"""Formats a human-readable M:SS string as a float (number of seconds).
|
||||
|
||||
Raises ValueError if the conversion cannot take place due to `string` not
|
||||
being in the right format.
|
||||
"""
|
||||
match = re.match(r"^(\d+):([0-5]\d)$", string)
|
||||
if not match:
|
||||
raise ValueError("String not in M:SS format")
|
||||
minutes, seconds = map(int, match.groups())
|
||||
return float(minutes * 60 + seconds)
|
||||
|
||||
|
||||
def human_seconds_short(interval):
|
||||
"""Formats a number of seconds as a short human-readable M:SS
|
||||
string.
|
||||
"""
|
||||
interval = int(interval)
|
||||
return f"{interval // 60}:{interval % 60:02d}"
|
||||
|
||||
|
||||
def human_bytes(size):
|
||||
"""Formats size, a number of bytes, in a human-readable way."""
|
||||
powers = ["", "K", "M", "G", "T", "P", "E", "Z", "Y", "H"]
|
||||
unit = "B"
|
||||
for power in powers:
|
||||
if size < 1024:
|
||||
return f"{size:3.1f} {power}{unit}"
|
||||
size /= 1024.0
|
||||
unit = "iB"
|
||||
return "big"
|
||||
|
||||
|
||||
def human_seconds(interval):
|
||||
"""Formats interval, a number of seconds, as a human-readable time
|
||||
interval using English words.
|
||||
"""
|
||||
units = [
|
||||
(1, "second"),
|
||||
(60, "minute"),
|
||||
(60, "hour"),
|
||||
(24, "day"),
|
||||
(7, "week"),
|
||||
(52, "year"),
|
||||
(10, "decade"),
|
||||
]
|
||||
for i in range(len(units) - 1):
|
||||
increment, suffix = units[i]
|
||||
next_increment, _ = units[i + 1]
|
||||
interval /= float(increment)
|
||||
if interval < next_increment:
|
||||
break
|
||||
else:
|
||||
# Last unit.
|
||||
increment, suffix = units[-1]
|
||||
interval /= float(increment)
|
||||
|
||||
return f"{interval:3.1f} {suffix}s"
|
||||
3
beetsplug/_utils/__init__.py
Normal file
3
beetsplug/_utils/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import art, vfs
|
||||
|
||||
__all__ = ["art", "vfs"]
|
||||
|
|
@ -38,11 +38,7 @@ def get_art(log, item):
|
|||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
log.warning(
|
||||
"Could not extract art from {0}: {1}",
|
||||
displayable_path(item.path),
|
||||
exc,
|
||||
)
|
||||
log.warning("Could not extract art from {.filepath}: {}", item, exc)
|
||||
return
|
||||
|
||||
return mf.art
|
||||
|
|
@ -83,16 +79,16 @@ def embed_item(
|
|||
|
||||
# Get the `Image` object from the file.
|
||||
try:
|
||||
log.debug("embedding {0}", displayable_path(imagepath))
|
||||
log.debug("embedding {}", displayable_path(imagepath))
|
||||
image = mediafile_image(imagepath, maxwidth)
|
||||
except OSError as exc:
|
||||
log.warning("could not read image file: {0}", exc)
|
||||
log.warning("could not read image file: {}", exc)
|
||||
return
|
||||
|
||||
# Make sure the image kind is safe (some formats only support PNG
|
||||
# and JPEG).
|
||||
if image.mime_type not in ("image/jpeg", "image/png"):
|
||||
log.info("not embedding image of unsupported type: {}", image.mime_type)
|
||||
log.info("not embedding image of unsupported type: {.mime_type}", image)
|
||||
return
|
||||
|
||||
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
|
||||
|
|
@ -110,11 +106,11 @@ def embed_album(
|
|||
"""Embed album art into all of the album's items."""
|
||||
imagepath = album.artpath
|
||||
if not imagepath:
|
||||
log.info("No album art present for {0}", album)
|
||||
log.info("No album art present for {}", album)
|
||||
return
|
||||
if not os.path.isfile(syspath(imagepath)):
|
||||
log.info(
|
||||
"Album art not found at {0} for {1}",
|
||||
"Album art not found at {} for {}",
|
||||
displayable_path(imagepath),
|
||||
album,
|
||||
)
|
||||
|
|
@ -122,7 +118,7 @@ def embed_album(
|
|||
if maxwidth:
|
||||
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
||||
|
||||
log.info("Embedding album art into {0}", album)
|
||||
log.info("Embedding album art into {}", album)
|
||||
|
||||
for item in album.items():
|
||||
embed_item(
|
||||
|
|
@ -143,8 +139,7 @@ def resize_image(log, imagepath, maxwidth, quality):
|
|||
specified quality level.
|
||||
"""
|
||||
log.debug(
|
||||
"Resizing album art to {0} pixels wide and encoding at quality \
|
||||
level {1}",
|
||||
"Resizing album art to {} pixels wide and encoding at quality level {}",
|
||||
maxwidth,
|
||||
quality,
|
||||
)
|
||||
|
|
@ -184,18 +179,18 @@ def extract(log, outpath, item):
|
|||
art = get_art(log, item)
|
||||
outpath = bytestring_path(outpath)
|
||||
if not art:
|
||||
log.info("No album art present in {0}, skipping.", item)
|
||||
log.info("No album art present in {}, skipping.", item)
|
||||
return
|
||||
|
||||
# Add an extension to the filename.
|
||||
ext = mediafile.image_extension(art)
|
||||
if not ext:
|
||||
log.warning("Unknown image type in {0}.", displayable_path(item.path))
|
||||
log.warning("Unknown image type in {.filepath}.", item)
|
||||
return
|
||||
outpath += bytestring_path("." + ext)
|
||||
outpath += bytestring_path(f".{ext}")
|
||||
|
||||
log.info(
|
||||
"Extracting album art from: {0} to: {1}",
|
||||
"Extracting album art from: {} to: {}",
|
||||
item,
|
||||
displayable_path(outpath),
|
||||
)
|
||||
|
|
@ -213,7 +208,7 @@ def extract_first(log, outpath, items):
|
|||
|
||||
def clear(log, lib, query):
|
||||
items = lib.items(query)
|
||||
log.info("Clearing album art from {0} items", len(items))
|
||||
log.info("Clearing album art from {} items", len(items))
|
||||
for item in items:
|
||||
log.debug("Clearing art for {0}", item)
|
||||
log.debug("Clearing art for {}", item)
|
||||
item.try_write(tags={"images": None})
|
||||
290
beetsplug/_utils/musicbrainz.py
Normal file
290
beetsplug/_utils/musicbrainz.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""Helpers for communicating with the MusicBrainz webservice.
|
||||
|
||||
Provides rate-limited HTTP session and convenience methods to fetch and
|
||||
normalize API responses.
|
||||
|
||||
This module centralizes request handling and response shaping so callers can
|
||||
work with consistently structured data without embedding HTTP or rate-limit
|
||||
logic throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cached_property, singledispatchmethod, wraps
|
||||
from itertools import groupby
|
||||
from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar
|
||||
|
||||
from requests_ratelimiter import LimiterMixin
|
||||
from typing_extensions import NotRequired, Unpack
|
||||
|
||||
from beets import config, logging
|
||||
|
||||
from .requests import RequestHandler, TimeoutAndRetrySession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from requests import Response
|
||||
|
||||
from .._typing import JSONDict
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LimiterTimeoutSession(LimiterMixin, TimeoutAndRetrySession):
|
||||
"""HTTP session that enforces rate limits."""
|
||||
|
||||
|
||||
Entity = Literal[
|
||||
"area",
|
||||
"artist",
|
||||
"collection",
|
||||
"event",
|
||||
"genre",
|
||||
"instrument",
|
||||
"label",
|
||||
"place",
|
||||
"recording",
|
||||
"release",
|
||||
"release-group",
|
||||
"series",
|
||||
"work",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
||||
class LookupKwargs(TypedDict, total=False):
|
||||
includes: NotRequired[list[str]]
|
||||
|
||||
|
||||
class PagingKwargs(TypedDict, total=False):
|
||||
limit: NotRequired[int]
|
||||
offset: NotRequired[int]
|
||||
|
||||
|
||||
class SearchKwargs(PagingKwargs):
|
||||
query: NotRequired[str]
|
||||
|
||||
|
||||
class BrowseKwargs(LookupKwargs, PagingKwargs, total=False):
|
||||
pass
|
||||
|
||||
|
||||
class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False):
|
||||
artist: NotRequired[str]
|
||||
collection: NotRequired[str]
|
||||
release: NotRequired[str]
|
||||
|
||||
|
||||
class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False):
|
||||
work: NotRequired[str]
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
required = frozenset(keys)
|
||||
|
||||
def deco(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
# kwargs is a real dict at runtime; safe to inspect here
|
||||
if not required & kwargs.keys():
|
||||
required_str = ", ".join(sorted(required))
|
||||
raise ValueError(
|
||||
f"At least one of {required_str} filter is required"
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
@dataclass
|
||||
class MusicBrainzAPI(RequestHandler):
|
||||
"""High-level interface to the MusicBrainz WS/2 API.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Configure the API host and request rate from application configuration.
|
||||
- Offer helpers to fetch common entity types and to run searches.
|
||||
- Normalize MusicBrainz responses so relation lists are grouped by target
|
||||
type for easier downstream consumption.
|
||||
|
||||
Documentation: https://musicbrainz.org/doc/MusicBrainz_API
|
||||
"""
|
||||
|
||||
api_host: str = field(init=False)
|
||||
rate_limit: float = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
mb_config = config["musicbrainz"]
|
||||
mb_config.add(
|
||||
{
|
||||
"host": "musicbrainz.org",
|
||||
"https": False,
|
||||
"ratelimit": 1,
|
||||
"ratelimit_interval": 1,
|
||||
}
|
||||
)
|
||||
|
||||
hostname = mb_config["host"].as_str()
|
||||
if hostname == "musicbrainz.org":
|
||||
self.api_host, self.rate_limit = "https://musicbrainz.org", 1.0
|
||||
else:
|
||||
https = mb_config["https"].get(bool)
|
||||
self.api_host = f"http{'s' if https else ''}://{hostname}"
|
||||
self.rate_limit = (
|
||||
mb_config["ratelimit"].get(int)
|
||||
/ mb_config["ratelimit_interval"].as_number()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def api_root(self) -> str:
|
||||
return f"{self.api_host}/ws/2"
|
||||
|
||||
def create_session(self) -> LimiterTimeoutSession:
|
||||
return LimiterTimeoutSession(per_second=self.rate_limit)
|
||||
|
||||
def request(self, *args, **kwargs) -> Response:
|
||||
"""Ensure all requests specify JSON response format by default."""
|
||||
kwargs.setdefault("params", {})
|
||||
kwargs["params"]["fmt"] = "json"
|
||||
return super().request(*args, **kwargs)
|
||||
|
||||
def _get_resource(
|
||||
self, resource: str, includes: list[str] | None = None, **kwargs
|
||||
) -> JSONDict:
|
||||
"""Retrieve and normalize data from the API resource endpoint.
|
||||
|
||||
If requested, includes are appended to the request. The response is
|
||||
passed through a normalizer that groups relation entries by their
|
||||
target type so that callers receive a consistently structured mapping.
|
||||
"""
|
||||
if includes:
|
||||
kwargs["inc"] = "+".join(includes)
|
||||
|
||||
return self._group_relations(
|
||||
self.get_json(f"{self.api_root}/{resource}", params=kwargs)
|
||||
)
|
||||
|
||||
def _lookup(
|
||||
self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs]
|
||||
) -> JSONDict:
|
||||
return self._get_resource(f"{entity}/{id_}", **kwargs)
|
||||
|
||||
def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]:
|
||||
return self._get_resource(entity, **kwargs).get(f"{entity}s", [])
|
||||
|
||||
def search(
|
||||
self,
|
||||
entity: Entity,
|
||||
filters: dict[str, str],
|
||||
**kwargs: Unpack[SearchKwargs],
|
||||
) -> list[JSONDict]:
|
||||
"""Search for MusicBrainz entities matching the given filters.
|
||||
|
||||
* Query is constructed by combining the provided filters using AND logic
|
||||
* Each filter key-value pair is formatted as 'key:"value"' unless
|
||||
- 'key' is empty, in which case only the value is used, '"value"'
|
||||
- 'value' is empty, in which case the filter is ignored
|
||||
* Values are lowercased and stripped of whitespace.
|
||||
"""
|
||||
query = " AND ".join(
|
||||
":".join(filter(None, (k, f'"{_v}"')))
|
||||
for k, v in filters.items()
|
||||
if (_v := v.lower().strip())
|
||||
)
|
||||
log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query)
|
||||
kwargs["query"] = query
|
||||
return self._get_resource(entity, **kwargs)[f"{entity}s"]
|
||||
|
||||
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
|
||||
"""Retrieve a release by its MusicBrainz ID."""
|
||||
return self._lookup("release", id_, **kwargs)
|
||||
|
||||
def get_recording(
|
||||
self, id_: str, **kwargs: Unpack[LookupKwargs]
|
||||
) -> JSONDict:
|
||||
"""Retrieve a recording by its MusicBrainz ID."""
|
||||
return self._lookup("recording", id_, **kwargs)
|
||||
|
||||
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
|
||||
"""Retrieve a work by its MusicBrainz ID."""
|
||||
return self._lookup("work", id_, **kwargs)
|
||||
|
||||
@require_one_of("artist", "collection", "release", "work")
|
||||
def browse_recordings(
|
||||
self, **kwargs: Unpack[BrowseRecordingsKwargs]
|
||||
) -> list[JSONDict]:
|
||||
"""Browse recordings related to the given entities.
|
||||
|
||||
At least one of artist, collection, release, or work must be provided.
|
||||
"""
|
||||
return self._browse("recording", **kwargs)
|
||||
|
||||
@require_one_of("artist", "collection", "release")
|
||||
def browse_release_groups(
|
||||
self, **kwargs: Unpack[BrowseReleaseGroupsKwargs]
|
||||
) -> list[JSONDict]:
|
||||
"""Browse release groups related to the given entities.
|
||||
|
||||
At least one of artist, collection, or release must be provided.
|
||||
"""
|
||||
return self._get_resource("release-group", **kwargs)["release-groups"]
|
||||
|
||||
@singledispatchmethod
|
||||
@classmethod
|
||||
def _group_relations(cls, data: Any) -> Any:
|
||||
"""Normalize MusicBrainz 'relations' into type-keyed fields recursively.
|
||||
|
||||
This helper rewrites payloads that use a generic 'relations' list into
|
||||
a structure that is easier to consume downstream. When a mapping
|
||||
contains 'relations', those entries are regrouped by their 'target-type'
|
||||
and stored under keys like '<target-type>-relations'. The original
|
||||
'relations' key is removed to avoid ambiguous access patterns.
|
||||
|
||||
The transformation is applied recursively so that nested objects and
|
||||
sequences are normalized consistently, while non-container values are
|
||||
left unchanged.
|
||||
"""
|
||||
return data
|
||||
|
||||
@_group_relations.register(list)
|
||||
@classmethod
|
||||
def _(cls, data: list[Any]) -> list[Any]:
|
||||
return [cls._group_relations(i) for i in data]
|
||||
|
||||
@_group_relations.register(dict)
|
||||
@classmethod
|
||||
def _(cls, data: JSONDict) -> JSONDict:
|
||||
for k, v in list(data.items()):
|
||||
if k == "relations":
|
||||
get_target_type = operator.methodcaller("get", "target-type")
|
||||
for target_type, group in groupby(
|
||||
sorted(v, key=get_target_type), get_target_type
|
||||
):
|
||||
relations = [
|
||||
{k: v for k, v in item.items() if k != "target-type"}
|
||||
for item in group
|
||||
]
|
||||
data[f"{target_type}-relations"] = cls._group_relations(
|
||||
relations
|
||||
)
|
||||
data.pop("relations")
|
||||
else:
|
||||
data[k] = cls._group_relations(v)
|
||||
return data
|
||||
|
||||
|
||||
class MusicBrainzAPIMixin:
|
||||
"""Mixin that provides a cached MusicBrainzAPI helper instance."""
|
||||
|
||||
@cached_property
|
||||
def mb_api(self) -> MusicBrainzAPI:
|
||||
return MusicBrainzAPI()
|
||||
196
beetsplug/_utils/requests.py
Normal file
196
beetsplug/_utils/requests.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from functools import cached_property
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from beets import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
class BeetsHTTPError(requests.exceptions.HTTPError):
|
||||
STATUS: ClassVar[HTTPStatus]
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(
|
||||
f"HTTP Error: {self.STATUS.value} {self.STATUS.phrase}",
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class HTTPNotFoundError(BeetsHTTPError):
|
||||
STATUS = HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
class Closeable(Protocol):
|
||||
"""Protocol for objects that have a close method."""
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
C = TypeVar("C", bound=Closeable)
|
||||
|
||||
|
||||
class SingletonMeta(type, Generic[C]):
|
||||
"""Metaclass ensuring a single shared instance per class.
|
||||
|
||||
Creates one instance per class type on first instantiation, reusing it
|
||||
for all subsequent calls. Automatically registers cleanup on program exit
|
||||
for proper resource management.
|
||||
"""
|
||||
|
||||
_instances: ClassVar[dict[type[Any], Any]] = {}
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> C:
|
||||
if cls not in cls._instances:
|
||||
with cls._lock:
|
||||
if cls not in SingletonMeta._instances:
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
SingletonMeta._instances[cls] = instance
|
||||
atexit.register(instance.close)
|
||||
return SingletonMeta._instances[cls]
|
||||
|
||||
|
||||
class TimeoutAndRetrySession(requests.Session, metaclass=SingletonMeta):
|
||||
"""HTTP session with sensible defaults.
|
||||
|
||||
* default beets User-Agent header
|
||||
* default request timeout
|
||||
* automatic retries on transient connection or server errors
|
||||
* raises exceptions for HTTP error status codes
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.headers["User-Agent"] = f"beets/{__version__} https://beets.io/"
|
||||
|
||||
retry = Retry(
|
||||
connect=2,
|
||||
total=2,
|
||||
backoff_factor=1,
|
||||
# Retry on server errors
|
||||
status_forcelist=[
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
HTTPStatus.GATEWAY_TIMEOUT,
|
||||
],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
self.mount("https://", adapter)
|
||||
self.mount("http://", adapter)
|
||||
|
||||
def request(self, *args, **kwargs):
|
||||
"""Execute HTTP request with automatic timeout and status validation.
|
||||
|
||||
Ensures all requests have a timeout (defaults to 10 seconds) and raises
|
||||
an exception for HTTP error status codes.
|
||||
"""
|
||||
kwargs.setdefault("timeout", 10)
|
||||
r = super().request(*args, **kwargs)
|
||||
r.raise_for_status()
|
||||
|
||||
return r
|
||||
|
||||
|
||||
class RequestHandler:
|
||||
"""Manages HTTP requests with custom error handling and session management.
|
||||
|
||||
Provides a reusable interface for making HTTP requests with automatic
|
||||
conversion of standard HTTP errors to beets-specific exceptions. Supports
|
||||
custom session types and error mappings that can be overridden by
|
||||
subclasses.
|
||||
|
||||
Usage:
|
||||
Subclass and override :class:`RequestHandler.create_session`,
|
||||
:class:`RequestHandler.explicit_http_errors` or
|
||||
:class:`RequestHandler.status_to_error()` to customize behavior.
|
||||
|
||||
Use
|
||||
|
||||
- :class:`RequestHandler.get_json()` to get JSON response data
|
||||
- :class:`RequestHandler.get()` to get HTTP response object
|
||||
- :class:`RequestHandler.request()` to invoke arbitrary HTTP methods
|
||||
|
||||
Feel free to define common methods that are used in multiple plugins.
|
||||
"""
|
||||
|
||||
#: List of custom exceptions to be raised for specific status codes.
|
||||
explicit_http_errors: ClassVar[list[type[BeetsHTTPError]]] = [
|
||||
HTTPNotFoundError
|
||||
]
|
||||
|
||||
def create_session(self) -> TimeoutAndRetrySession:
|
||||
"""Create a new HTTP session instance.
|
||||
|
||||
Can be overridden by subclasses to provide custom session types.
|
||||
"""
|
||||
return TimeoutAndRetrySession()
|
||||
|
||||
@cached_property
|
||||
def session(self) -> TimeoutAndRetrySession:
|
||||
return self.create_session()
|
||||
|
||||
def status_to_error(
|
||||
self, code: int
|
||||
) -> type[requests.exceptions.HTTPError] | None:
|
||||
"""Map HTTP status codes to beets-specific exception types.
|
||||
|
||||
Searches the configured explicit HTTP errors for a matching status code.
|
||||
Returns None if no specific error type is registered for the given code.
|
||||
"""
|
||||
return next(
|
||||
(e for e in self.explicit_http_errors if e.STATUS == code), None
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def handle_http_error(self) -> Iterator[None]:
|
||||
"""Convert standard HTTP errors to beets-specific exceptions.
|
||||
|
||||
Wraps operations that may raise HTTPError, automatically translating
|
||||
recognized status codes into their corresponding beets exception types.
|
||||
Unrecognized errors are re-raised unchanged.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if beets_error := self.status_to_error(e.response.status_code):
|
||||
raise beets_error(response=e.response) from e
|
||||
|
||||
raise
|
||||
|
||||
def request(self, *args, **kwargs) -> requests.Response:
|
||||
"""Perform HTTP request using the session with automatic error handling.
|
||||
|
||||
Delegates to the underlying session method while converting recognized
|
||||
HTTP errors to beets-specific exceptions through the error handler.
|
||||
"""
|
||||
with self.handle_http_error():
|
||||
return self.session.request(*args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs) -> requests.Response:
|
||||
"""Perform HTTP GET request with automatic error handling."""
|
||||
return self.request("get", *args, **kwargs)
|
||||
|
||||
def put(self, *args, **kwargs) -> requests.Response:
|
||||
"""Perform HTTP PUT request with automatic error handling."""
|
||||
return self.request("put", *args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs) -> requests.Response:
|
||||
"""Perform HTTP DELETE request with automatic error handling."""
|
||||
return self.request("delete", *args, **kwargs)
|
||||
|
||||
def get_json(self, *args, **kwargs):
|
||||
"""Fetch and parse JSON data from an HTTP endpoint."""
|
||||
return self.get(*args, **kwargs).json()
|
||||
|
|
@ -16,17 +16,25 @@
|
|||
libraries.
|
||||
"""
|
||||
|
||||
from typing import Any, NamedTuple
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from beets import util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.library import Library
|
||||
|
||||
|
||||
class Node(NamedTuple):
|
||||
files: dict[str, Any]
|
||||
dirs: dict[str, Any]
|
||||
files: dict[str, int]
|
||||
# Maps filenames to Item ids.
|
||||
|
||||
dirs: dict[str, Node]
|
||||
# Maps directory names to child nodes.
|
||||
|
||||
|
||||
def _insert(node, path, itemid):
|
||||
def _insert(node: Node, path: list[str], itemid: int):
|
||||
"""Insert an item into a virtual filesystem node."""
|
||||
if len(path) == 1:
|
||||
# Last component. Insert file.
|
||||
|
|
@ -40,7 +48,7 @@ def _insert(node, path, itemid):
|
|||
_insert(node.dirs[dirname], rest, itemid)
|
||||
|
||||
|
||||
def libtree(lib):
|
||||
def libtree(lib: Library) -> Node:
|
||||
"""Generates a filesystem-like directory tree for the files
|
||||
contained in `lib`. Filesystem nodes are (files, dirs) named
|
||||
tuples in which both components are dictionaries. The first
|
||||
|
|
@ -49,7 +57,7 @@ def libtree(lib):
|
|||
"""
|
||||
root = Node({}, {})
|
||||
for item in lib.items():
|
||||
dest = item.destination(fragment=True)
|
||||
parts = util.components(dest)
|
||||
dest = item.destination(relative_to_libdir=True)
|
||||
parts = util.components(util.as_string(dest))
|
||||
_insert(root, parts, item.id)
|
||||
return root
|
||||
|
|
@ -18,9 +18,9 @@ import errno
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from distutils.spawn import find_executable
|
||||
|
||||
import requests
|
||||
|
||||
|
|
@ -42,9 +42,7 @@ def call(args):
|
|||
try:
|
||||
return util.command_output(args).stdout
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise ABSubmitError(
|
||||
"{} exited with status {}".format(args[0], e.returncode)
|
||||
)
|
||||
raise ABSubmitError(f"{args[0]} exited with status {e.returncode}")
|
||||
|
||||
|
||||
class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
||||
|
|
@ -63,9 +61,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
# Explicit path to extractor
|
||||
if not os.path.isfile(self.extractor):
|
||||
raise ui.UserError(
|
||||
"Extractor command does not exist: {0}.".format(
|
||||
self.extractor
|
||||
)
|
||||
f"Extractor command does not exist: {self.extractor}."
|
||||
)
|
||||
else:
|
||||
# Implicit path to extractor, search for it in path
|
||||
|
|
@ -84,7 +80,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
|
||||
# Get the executable location on the system, which we need
|
||||
# to calculate the SHA-1 hash.
|
||||
self.extractor = find_executable(self.extractor)
|
||||
self.extractor = shutil.which(self.extractor)
|
||||
|
||||
# Calculate extractor hash.
|
||||
self.extractor_sha = hashlib.sha1()
|
||||
|
|
@ -101,8 +97,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
"with an HTTP scheme"
|
||||
)
|
||||
elif base_url[-1] != "/":
|
||||
base_url = base_url + "/"
|
||||
self.url = base_url + "{mbid}/low-level"
|
||||
base_url = f"{base_url}/"
|
||||
self.url = f"{base_url}{{mbid}}/low-level"
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand(
|
||||
|
|
@ -122,8 +118,10 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
dest="pretend_fetch",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="pretend to perform action, but show \
|
||||
only files which would be processed",
|
||||
help=(
|
||||
"pretend to perform action, but show only files which would be"
|
||||
" processed"
|
||||
),
|
||||
)
|
||||
cmd.func = self.command
|
||||
return [cmd]
|
||||
|
|
@ -137,7 +135,7 @@ only files which would be processed",
|
|||
)
|
||||
else:
|
||||
# Get items from arguments
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self.opts = opts
|
||||
util.par_map(self.analyze_submit, items)
|
||||
|
||||
|
|
@ -157,7 +155,7 @@ only files which would be processed",
|
|||
# If file has no MBID, skip it.
|
||||
if not mbid:
|
||||
self._log.info(
|
||||
"Not analysing {}, missing " "musicbrainz track id.", item
|
||||
"Not analysing {}, missing musicbrainz track id.", item
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
@ -220,6 +218,6 @@ only files which would be processed",
|
|||
)
|
||||
else:
|
||||
self._log.debug(
|
||||
"Successfully submitted AcousticBrainz analysis " "for {}.",
|
||||
"Successfully submitted AcousticBrainz analysis for {}.",
|
||||
item,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
|
|||
"with an HTTP scheme"
|
||||
)
|
||||
elif self.base_url[-1] != "/":
|
||||
self.base_url = self.base_url + "/"
|
||||
self.base_url = f"{self.base_url}/"
|
||||
|
||||
if self.config["auto"]:
|
||||
self.register_listener("import_task_files", self.import_task_files)
|
||||
|
|
@ -116,7 +116,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
|
|||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self._fetch_info(
|
||||
items,
|
||||
ui.should_write(),
|
||||
|
|
@ -153,7 +153,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
|
|||
try:
|
||||
data.update(res.json())
|
||||
except ValueError:
|
||||
self._log.debug("Invalid Response: {}", res.text)
|
||||
self._log.debug("Invalid Response: {.text}", res)
|
||||
return {}
|
||||
|
||||
return data
|
||||
|
|
@ -286,7 +286,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
|
|||
yield v, subdata[k]
|
||||
else:
|
||||
self._log.warning(
|
||||
"Acousticbrainz did not provide info " "about {}", k
|
||||
"Acousticbrainz did not provide info about {}", k
|
||||
)
|
||||
self._log.debug(
|
||||
"Data {} could not be mapped to scheme {} "
|
||||
|
|
@ -300,4 +300,4 @@ class AcousticPlugin(plugins.BeetsPlugin):
|
|||
def _generate_urls(base_url, mbid):
|
||||
"""Generates AcousticBrainz end point urls for given `mbid`."""
|
||||
for level in LEVELS:
|
||||
yield base_url + mbid + level
|
||||
yield f"{base_url}{mbid}{level}"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ class AdvancedRewritePlugin(BeetsPlugin):
|
|||
def __init__(self):
|
||||
"""Parse configuration and register template fields for rewriting."""
|
||||
super().__init__()
|
||||
self.register_listener("pluginload", self.loaded)
|
||||
|
||||
def loaded(self):
|
||||
template = confuse.Sequence(
|
||||
confuse.OneOf(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@
|
|||
|
||||
"""Adds an album template field for formatted album types."""
|
||||
|
||||
from beets.autotag.mb import VARIOUS_ARTISTS_ID
|
||||
from beets.library import Album
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
from .musicbrainz import VARIOUS_ARTISTS_ID
|
||||
|
||||
|
||||
class AlbumTypesPlugin(BeetsPlugin):
|
||||
"""Adds an album template field for formatted album types."""
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from mimetypes import guess_type
|
||||
|
|
@ -30,11 +29,7 @@ from flask import (
|
|||
request,
|
||||
send_file,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self
|
||||
else:
|
||||
from typing_extensions import Self
|
||||
from typing_extensions import Self
|
||||
|
||||
from beets import config
|
||||
from beets.dbcore.query import (
|
||||
|
|
@ -241,14 +236,14 @@ class AURADocument:
|
|||
# Not the last page so work out links.next url
|
||||
if not self.args:
|
||||
# No existing arguments, so current page is 0
|
||||
next_url = request.url + "?page=1"
|
||||
next_url = f"{request.url}?page=1"
|
||||
elif not self.args.get("page", None):
|
||||
# No existing page argument, so add one to the end
|
||||
next_url = request.url + "&page=1"
|
||||
next_url = f"{request.url}&page=1"
|
||||
else:
|
||||
# Increment page token by 1
|
||||
next_url = request.url.replace(
|
||||
f"page={page}", "page={}".format(page + 1)
|
||||
f"page={page}", f"page={page + 1}"
|
||||
)
|
||||
# Get only the items in the page range
|
||||
data = [
|
||||
|
|
@ -432,9 +427,7 @@ class TrackDocument(AURADocument):
|
|||
return self.error(
|
||||
"404 Not Found",
|
||||
"No track with the requested id.",
|
||||
"There is no track with an id of {} in the library.".format(
|
||||
track_id
|
||||
),
|
||||
f"There is no track with an id of {track_id} in the library.",
|
||||
)
|
||||
return self.single_resource_document(
|
||||
self.get_resource_object(self.lib, track)
|
||||
|
|
@ -518,9 +511,7 @@ class AlbumDocument(AURADocument):
|
|||
return self.error(
|
||||
"404 Not Found",
|
||||
"No album with the requested id.",
|
||||
"There is no album with an id of {} in the library.".format(
|
||||
album_id
|
||||
),
|
||||
f"There is no album with an id of {album_id} in the library.",
|
||||
)
|
||||
return self.single_resource_document(
|
||||
self.get_resource_object(self.lib, album)
|
||||
|
|
@ -605,9 +596,7 @@ class ArtistDocument(AURADocument):
|
|||
return self.error(
|
||||
"404 Not Found",
|
||||
"No artist with the requested id.",
|
||||
"There is no artist with an id of {} in the library.".format(
|
||||
artist_id
|
||||
),
|
||||
f"There is no artist with an id of {artist_id} in the library.",
|
||||
)
|
||||
return self.single_resource_document(artist_resource)
|
||||
|
||||
|
|
@ -708,7 +697,7 @@ class ImageDocument(AURADocument):
|
|||
relationships = {}
|
||||
# Split id into [parent_type, parent_id, filename]
|
||||
id_split = image_id.split("-")
|
||||
relationships[id_split[0] + "s"] = {
|
||||
relationships[f"{id_split[0]}s"] = {
|
||||
"data": [{"type": id_split[0], "id": id_split[1]}]
|
||||
}
|
||||
|
||||
|
|
@ -732,9 +721,7 @@ class ImageDocument(AURADocument):
|
|||
return self.error(
|
||||
"404 Not Found",
|
||||
"No image with the requested id.",
|
||||
"There is no image with an id of {} in the library.".format(
|
||||
image_id
|
||||
),
|
||||
f"There is no image with an id of {image_id} in the library.",
|
||||
)
|
||||
return self.single_resource_document(image_resource)
|
||||
|
||||
|
|
@ -780,9 +767,7 @@ def audio_file(track_id):
|
|||
return AURADocument.error(
|
||||
"404 Not Found",
|
||||
"No track with the requested id.",
|
||||
"There is no track with an id of {} in the library.".format(
|
||||
track_id
|
||||
),
|
||||
f"There is no track with an id of {track_id} in the library.",
|
||||
)
|
||||
|
||||
path = os.fsdecode(track.path)
|
||||
|
|
@ -790,9 +775,8 @@ def audio_file(track_id):
|
|||
return AURADocument.error(
|
||||
"404 Not Found",
|
||||
"No audio file for the requested track.",
|
||||
(
|
||||
"There is no audio file for track {} at the expected location"
|
||||
).format(track_id),
|
||||
f"There is no audio file for track {track_id} at the expected"
|
||||
" location",
|
||||
)
|
||||
|
||||
file_mimetype = guess_type(path)[0]
|
||||
|
|
@ -800,10 +784,8 @@ def audio_file(track_id):
|
|||
return AURADocument.error(
|
||||
"500 Internal Server Error",
|
||||
"Requested audio file has an unknown mimetype.",
|
||||
(
|
||||
"The audio file for track {} has an unknown mimetype. "
|
||||
"Its file extension is {}."
|
||||
).format(track_id, path.split(".")[-1]),
|
||||
f"The audio file for track {track_id} has an unknown mimetype. "
|
||||
f"Its file extension is {path.split('.')[-1]}.",
|
||||
)
|
||||
|
||||
# Check that the Accept header contains the file's mimetype
|
||||
|
|
@ -815,10 +797,8 @@ def audio_file(track_id):
|
|||
return AURADocument.error(
|
||||
"406 Not Acceptable",
|
||||
"Unsupported MIME type or bitrate parameter in Accept header.",
|
||||
(
|
||||
"The audio file for track {} is only available as {} and "
|
||||
"bitrate parameters are not supported."
|
||||
).format(track_id, file_mimetype),
|
||||
f"The audio file for track {track_id} is only available as"
|
||||
f" {file_mimetype} and bitrate parameters are not supported.",
|
||||
)
|
||||
|
||||
return send_file(
|
||||
|
|
@ -901,9 +881,7 @@ def image_file(image_id):
|
|||
return AURADocument.error(
|
||||
"404 Not Found",
|
||||
"No image with the requested id.",
|
||||
"There is no image with an id of {} in the library".format(
|
||||
image_id
|
||||
),
|
||||
f"There is no image with an id of {image_id} in the library",
|
||||
)
|
||||
return send_file(img_path)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, should_write
|
||||
|
|
@ -76,7 +76,10 @@ class AutoBPMPlugin(BeetsPlugin):
|
|||
self._log.error("Failed to measure BPM for {}: {}", path, exc)
|
||||
continue
|
||||
|
||||
bpm = round(tempo[0] if isinstance(tempo, Iterable) else tempo)
|
||||
bpm = round(
|
||||
float(tempo[0] if isinstance(tempo, np.ndarray) else tempo)
|
||||
)
|
||||
|
||||
item["bpm"] = bpm
|
||||
self._log.info("Computed BPM for {}: {}", path, bpm)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ class BadFiles(BeetsPlugin):
|
|||
self._log.debug("checking path: {}", dpath)
|
||||
if not os.path.exists(item.path):
|
||||
ui.print_(
|
||||
"{}: file does not exist".format(
|
||||
ui.colorize("text_error", dpath)
|
||||
)
|
||||
f"{ui.colorize('text_error', dpath)}: file does not exist"
|
||||
)
|
||||
|
||||
# Run the checker against the file if one is found
|
||||
|
|
@ -129,37 +127,32 @@ class BadFiles(BeetsPlugin):
|
|||
except CheckerCommandError as e:
|
||||
if e.errno == errno.ENOENT:
|
||||
self._log.error(
|
||||
"command not found: {} when validating file: {}",
|
||||
e.checker,
|
||||
e.path,
|
||||
"command not found: {0.checker} when validating file: {0.path}",
|
||||
e,
|
||||
)
|
||||
else:
|
||||
self._log.error("error invoking {}: {}", e.checker, e.msg)
|
||||
self._log.error("error invoking {0.checker}: {0.msg}", e)
|
||||
return []
|
||||
|
||||
error_lines = []
|
||||
|
||||
if status > 0:
|
||||
error_lines.append(
|
||||
"{}: checker exited with status {}".format(
|
||||
ui.colorize("text_error", dpath), status
|
||||
)
|
||||
f"{ui.colorize('text_error', dpath)}: checker exited with"
|
||||
f" status {status}"
|
||||
)
|
||||
for line in output:
|
||||
error_lines.append(f" {line}")
|
||||
|
||||
elif errors > 0:
|
||||
error_lines.append(
|
||||
"{}: checker found {} errors or warnings".format(
|
||||
ui.colorize("text_warning", dpath), errors
|
||||
)
|
||||
f"{ui.colorize('text_warning', dpath)}: checker found"
|
||||
f" {status} errors or warnings"
|
||||
)
|
||||
for line in output:
|
||||
error_lines.append(f" {line}")
|
||||
elif self.verbose:
|
||||
error_lines.append(
|
||||
"{}: ok".format(ui.colorize("text_success", dpath))
|
||||
)
|
||||
error_lines.append(f"{ui.colorize('text_success', dpath)}: ok")
|
||||
|
||||
return error_lines
|
||||
|
||||
|
|
@ -180,9 +173,8 @@ class BadFiles(BeetsPlugin):
|
|||
def on_import_task_before_choice(self, task, session):
|
||||
if hasattr(task, "_badfiles_checks_failed"):
|
||||
ui.print_(
|
||||
"{} one or more files failed checks:".format(
|
||||
ui.colorize("text_warning", "BAD")
|
||||
)
|
||||
f"{ui.colorize('text_warning', 'BAD')} one or more files failed"
|
||||
" checks:"
|
||||
)
|
||||
for error in task._badfiles_checks_failed:
|
||||
for error_line in error:
|
||||
|
|
@ -194,7 +186,7 @@ class BadFiles(BeetsPlugin):
|
|||
sel = ui.input_options(["aBort", "skip", "continue"])
|
||||
|
||||
if sel == "s":
|
||||
return importer.action.SKIP
|
||||
return importer.Action.SKIP
|
||||
elif sel == "c":
|
||||
return None
|
||||
elif sel == "b":
|
||||
|
|
@ -204,7 +196,7 @@ class BadFiles(BeetsPlugin):
|
|||
|
||||
def command(self, lib, opts, args):
|
||||
# Get items from arguments
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self.verbose = opts.verbose
|
||||
|
||||
def check_and_print(item):
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from unidecode import unidecode
|
|||
from beets import ui
|
||||
from beets.dbcore.query import StringFieldQuery
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs, print_
|
||||
from beets.ui import print_
|
||||
|
||||
|
||||
class BareascQuery(StringFieldQuery[str]):
|
||||
|
|
@ -75,7 +75,7 @@ class BareascPlugin(BeetsPlugin):
|
|||
"bareasc", help="unidecode version of beet list command"
|
||||
)
|
||||
cmd.parser.usage += (
|
||||
"\n" "Example: %prog -f '$album: $title' artist:beatles"
|
||||
"\nExample: %prog -f '$album: $title' artist:beatles"
|
||||
)
|
||||
cmd.parser.add_all_common_options()
|
||||
cmd.func = self.unidecode_list
|
||||
|
|
@ -83,14 +83,13 @@ class BareascPlugin(BeetsPlugin):
|
|||
|
||||
def unidecode_list(self, lib, opts, args):
|
||||
"""Emulate normal 'list' command but with unidecode output."""
|
||||
query = decargs(args)
|
||||
album = opts.album
|
||||
# Copied from commands.py - list_items
|
||||
if album:
|
||||
for album in lib.albums(query):
|
||||
for album in lib.albums(args):
|
||||
bare = unidecode(str(album))
|
||||
print_(bare)
|
||||
else:
|
||||
for item in lib.items(query):
|
||||
for item in lib.items(args):
|
||||
bare = unidecode(str(item))
|
||||
print_(bare)
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@
|
|||
|
||||
"""Adds Beatport release and track search support to the autotagger"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import confuse
|
||||
from requests_oauthlib import OAuth1Session
|
||||
|
|
@ -29,8 +32,15 @@ from requests_oauthlib.oauth1_session import (
|
|||
import beets
|
||||
import beets.ui
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
|
||||
from beets.util.id_extractors import beatport_id_regex
|
||||
from beets.metadata_plugins import MetadataSourcePlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
|
||||
from beets.importer import ImportSession
|
||||
from beets.library import Item
|
||||
|
||||
from ._typing import JSONDict
|
||||
|
||||
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
|
||||
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
|
||||
|
|
@ -40,20 +50,6 @@ class BeatportAPIError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class BeatportObject:
|
||||
def __init__(self, data):
|
||||
self.beatport_id = data["id"]
|
||||
self.name = str(data["name"])
|
||||
if "releaseDate" in data:
|
||||
self.release_date = datetime.strptime(
|
||||
data["releaseDate"], "%Y-%m-%d"
|
||||
)
|
||||
if "artists" in data:
|
||||
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
|
||||
if "genres" in data:
|
||||
self.genres = [str(x["name"]) for x in data["genres"]]
|
||||
|
||||
|
||||
class BeatportClient:
|
||||
_api_base = "https://oauth-api.beatport.com"
|
||||
|
||||
|
|
@ -78,7 +74,7 @@ class BeatportClient:
|
|||
)
|
||||
self.api.headers = {"User-Agent": USER_AGENT}
|
||||
|
||||
def get_authorize_url(self):
|
||||
def get_authorize_url(self) -> str:
|
||||
"""Generate the URL for the user to authorize the application.
|
||||
|
||||
Retrieves a request token from the Beatport API and returns the
|
||||
|
|
@ -100,38 +96,53 @@ class BeatportClient:
|
|||
self._make_url("/identity/1/oauth/authorize")
|
||||
)
|
||||
|
||||
def get_access_token(self, auth_data):
|
||||
def get_access_token(self, auth_data: str) -> tuple[str, str]:
|
||||
"""Obtain the final access token and secret for the API.
|
||||
|
||||
:param auth_data: URL-encoded authorization data as displayed at
|
||||
the authorization url (obtained via
|
||||
:py:meth:`get_authorize_url`) after signing in
|
||||
:type auth_data: unicode
|
||||
:returns: OAuth resource owner key and secret
|
||||
:rtype: (unicode, unicode) tuple
|
||||
:returns: OAuth resource owner key and secret as unicode
|
||||
"""
|
||||
self.api.parse_authorization_response(
|
||||
"https://beets.io/auth?" + auth_data
|
||||
f"https://beets.io/auth?{auth_data}"
|
||||
)
|
||||
access_data = self.api.fetch_access_token(
|
||||
self._make_url("/identity/1/oauth/access-token")
|
||||
)
|
||||
return access_data["oauth_token"], access_data["oauth_token_secret"]
|
||||
|
||||
def search(self, query, release_type="release", details=True):
|
||||
@overload
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
release_type: Literal["release"],
|
||||
details: bool = True,
|
||||
) -> Iterator[BeatportRelease]: ...
|
||||
|
||||
@overload
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
release_type: Literal["track"],
|
||||
details: bool = True,
|
||||
) -> Iterator[BeatportTrack]: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
release_type: Literal["release", "track"],
|
||||
details=True,
|
||||
) -> Iterator[BeatportRelease | BeatportTrack]:
|
||||
"""Perform a search of the Beatport catalogue.
|
||||
|
||||
:param query: Query string
|
||||
:param release_type: Type of releases to search for, can be
|
||||
'release' or 'track'
|
||||
:param release_type: Type of releases to search for.
|
||||
:param details: Retrieve additional information about the
|
||||
search results. Currently this will fetch
|
||||
the tracklist for releases and do nothing for
|
||||
tracks
|
||||
:returns: Search results
|
||||
:rtype: generator that yields
|
||||
py:class:`BeatportRelease` or
|
||||
:py:class:`BeatportTrack`
|
||||
"""
|
||||
response = self._get(
|
||||
"catalog/3/search",
|
||||
|
|
@ -141,20 +152,18 @@ class BeatportClient:
|
|||
)
|
||||
for item in response:
|
||||
if release_type == "release":
|
||||
release = BeatportRelease(item)
|
||||
if details:
|
||||
release = self.get_release(item["id"])
|
||||
else:
|
||||
release = BeatportRelease(item)
|
||||
release.tracks = self.get_release_tracks(item["id"])
|
||||
yield release
|
||||
elif release_type == "track":
|
||||
yield BeatportTrack(item)
|
||||
|
||||
def get_release(self, beatport_id):
|
||||
def get_release(self, beatport_id: str) -> BeatportRelease | None:
|
||||
"""Get information about a single release.
|
||||
|
||||
:param beatport_id: Beatport ID of the release
|
||||
:returns: The matching release
|
||||
:rtype: :py:class:`BeatportRelease`
|
||||
"""
|
||||
response = self._get("/catalog/3/releases", id=beatport_id)
|
||||
if response:
|
||||
|
|
@ -163,35 +172,33 @@ class BeatportClient:
|
|||
return release
|
||||
return None
|
||||
|
||||
def get_release_tracks(self, beatport_id):
|
||||
def get_release_tracks(self, beatport_id: str) -> list[BeatportTrack]:
|
||||
"""Get all tracks for a given release.
|
||||
|
||||
:param beatport_id: Beatport ID of the release
|
||||
:returns: Tracks in the matching release
|
||||
:rtype: list of :py:class:`BeatportTrack`
|
||||
"""
|
||||
response = self._get(
|
||||
"/catalog/3/tracks", releaseId=beatport_id, perPage=100
|
||||
)
|
||||
return [BeatportTrack(t) for t in response]
|
||||
|
||||
def get_track(self, beatport_id):
|
||||
def get_track(self, beatport_id: str) -> BeatportTrack:
|
||||
"""Get information about a single track.
|
||||
|
||||
:param beatport_id: Beatport ID of the track
|
||||
:returns: The matching track
|
||||
:rtype: :py:class:`BeatportTrack`
|
||||
"""
|
||||
response = self._get("/catalog/3/tracks", id=beatport_id)
|
||||
return BeatportTrack(response[0])
|
||||
|
||||
def _make_url(self, endpoint):
|
||||
def _make_url(self, endpoint: str) -> str:
|
||||
"""Get complete URL for a given API endpoint."""
|
||||
if not endpoint.startswith("/"):
|
||||
endpoint = "/" + endpoint
|
||||
return self._api_base + endpoint
|
||||
endpoint = f"/{endpoint}"
|
||||
return f"{self._api_base}{endpoint}"
|
||||
|
||||
def _get(self, endpoint, **kwargs):
|
||||
def _get(self, endpoint: str, **kwargs) -> list[JSONDict]:
|
||||
"""Perform a GET request on a given API endpoint.
|
||||
|
||||
Automatically extracts result data from the response and converts HTTP
|
||||
|
|
@ -200,60 +207,88 @@ class BeatportClient:
|
|||
try:
|
||||
response = self.api.get(self._make_url(endpoint), params=kwargs)
|
||||
except Exception as e:
|
||||
raise BeatportAPIError(
|
||||
"Error connecting to Beatport API: {}".format(e)
|
||||
)
|
||||
raise BeatportAPIError(f"Error connecting to Beatport API: {e}")
|
||||
if not response:
|
||||
raise BeatportAPIError(
|
||||
"Error {0.status_code} for '{0.request.path_url}".format(
|
||||
response
|
||||
)
|
||||
f"Error {response.status_code} for '{response.request.path_url}"
|
||||
)
|
||||
return response.json()["results"]
|
||||
|
||||
|
||||
class BeatportRelease(BeatportObject):
|
||||
def __str__(self):
|
||||
if len(self.artists) < 4:
|
||||
artist_str = ", ".join(x[1] for x in self.artists)
|
||||
else:
|
||||
artist_str = "Various Artists"
|
||||
return "<BeatportRelease: {} - {} ({})>".format(
|
||||
artist_str,
|
||||
self.name,
|
||||
self.catalog_number,
|
||||
)
|
||||
class BeatportObject:
|
||||
beatport_id: str
|
||||
name: str
|
||||
|
||||
def __repr__(self):
|
||||
return str(self).encode("utf-8")
|
||||
release_date: datetime | None = None
|
||||
|
||||
def __init__(self, data):
|
||||
BeatportObject.__init__(self, data)
|
||||
if "catalogNumber" in data:
|
||||
self.catalog_number = data["catalogNumber"]
|
||||
if "label" in data:
|
||||
self.label_name = data["label"]["name"]
|
||||
if "category" in data:
|
||||
self.category = data["category"]
|
||||
if "slug" in data:
|
||||
self.url = "https://beatport.com/release/{}/{}".format(
|
||||
data["slug"], data["id"]
|
||||
artists: list[tuple[str, str]] | None = None
|
||||
# tuple of artist id and artist name
|
||||
|
||||
def __init__(self, data: JSONDict):
|
||||
self.beatport_id = str(data["id"]) # given as int in the response
|
||||
self.name = str(data["name"])
|
||||
if "releaseDate" in data:
|
||||
self.release_date = datetime.strptime(
|
||||
data["releaseDate"], "%Y-%m-%d"
|
||||
)
|
||||
if "artists" in data:
|
||||
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
|
||||
if "genres" in data:
|
||||
self.genres = [str(x["name"]) for x in data["genres"]]
|
||||
|
||||
def artists_str(self) -> str | None:
|
||||
if self.artists is not None:
|
||||
if len(self.artists) < 4:
|
||||
artist_str = ", ".join(x[1] for x in self.artists)
|
||||
else:
|
||||
artist_str = "Various Artists"
|
||||
else:
|
||||
artist_str = None
|
||||
|
||||
return artist_str
|
||||
|
||||
|
||||
class BeatportRelease(BeatportObject):
|
||||
catalog_number: str | None
|
||||
label_name: str | None
|
||||
category: str | None
|
||||
url: str | None
|
||||
genre: str | None
|
||||
|
||||
tracks: list[BeatportTrack] | None = None
|
||||
|
||||
def __init__(self, data: JSONDict):
|
||||
super().__init__(data)
|
||||
|
||||
self.catalog_number = data.get("catalogNumber")
|
||||
self.label_name = data.get("label", {}).get("name")
|
||||
self.category = data.get("category")
|
||||
self.genre = data.get("genre")
|
||||
|
||||
if "slug" in data:
|
||||
self.url = (
|
||||
f"https://beatport.com/release/{data['slug']}/{data['id']}"
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
"<BeatportRelease: "
|
||||
f"{self.artists_str()} - {self.name} ({self.catalog_number})>"
|
||||
)
|
||||
|
||||
|
||||
class BeatportTrack(BeatportObject):
|
||||
def __str__(self):
|
||||
artist_str = ", ".join(x[1] for x in self.artists)
|
||||
return "<BeatportTrack: {} - {} ({})>".format(
|
||||
artist_str, self.name, self.mix_name
|
||||
)
|
||||
title: str | None
|
||||
mix_name: str | None
|
||||
length: timedelta
|
||||
url: str | None
|
||||
track_number: int | None
|
||||
bpm: str | None
|
||||
initial_key: str | None
|
||||
genre: str | None
|
||||
|
||||
def __repr__(self):
|
||||
return str(self).encode("utf-8")
|
||||
|
||||
def __init__(self, data):
|
||||
BeatportObject.__init__(self, data)
|
||||
def __init__(self, data: JSONDict):
|
||||
super().__init__(data)
|
||||
if "title" in data:
|
||||
self.title = str(data["title"])
|
||||
if "mixName" in data:
|
||||
|
|
@ -266,9 +301,7 @@ class BeatportTrack(BeatportObject):
|
|||
except ValueError:
|
||||
pass
|
||||
if "slug" in data:
|
||||
self.url = "https://beatport.com/track/{}/{}".format(
|
||||
data["slug"], data["id"]
|
||||
)
|
||||
self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}"
|
||||
self.track_number = data.get("trackNumber")
|
||||
self.bpm = data.get("bpm")
|
||||
self.initial_key = str((data.get("key") or {}).get("shortName"))
|
||||
|
|
@ -280,9 +313,8 @@ class BeatportTrack(BeatportObject):
|
|||
self.genre = str(data["genres"][0].get("name"))
|
||||
|
||||
|
||||
class BeatportPlugin(BeetsPlugin):
|
||||
data_source = "Beatport"
|
||||
id_regex = beatport_id_regex
|
||||
class BeatportPlugin(MetadataSourcePlugin):
|
||||
_client: BeatportClient | None = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -291,17 +323,23 @@ class BeatportPlugin(BeetsPlugin):
|
|||
"apikey": "57713c3906af6f5def151b33601389176b37b429",
|
||||
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
|
||||
"tokenfile": "beatport_token.json",
|
||||
"source_weight": 0.5,
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
self.config["apisecret"].redact = True
|
||||
self.client = None
|
||||
self.register_listener("import_begin", self.setup)
|
||||
|
||||
def setup(self, session=None):
|
||||
c_key = self.config["apikey"].as_str()
|
||||
c_secret = self.config["apisecret"].as_str()
|
||||
@property
|
||||
def client(self) -> BeatportClient:
|
||||
if self._client is None:
|
||||
raise ValueError(
|
||||
"Beatport client not initialized. Call setup() first."
|
||||
)
|
||||
return self._client
|
||||
|
||||
def setup(self, session: ImportSession):
|
||||
c_key: str = self.config["apikey"].as_str()
|
||||
c_secret: str = self.config["apisecret"].as_str()
|
||||
|
||||
# Get the OAuth token from a file or log in.
|
||||
try:
|
||||
|
|
@ -314,15 +352,15 @@ class BeatportPlugin(BeetsPlugin):
|
|||
token = tokendata["token"]
|
||||
secret = tokendata["secret"]
|
||||
|
||||
self.client = BeatportClient(c_key, c_secret, token, secret)
|
||||
self._client = BeatportClient(c_key, c_secret, token, secret)
|
||||
|
||||
def authenticate(self, c_key, c_secret):
|
||||
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
|
||||
# Get the link for the OAuth page.
|
||||
auth_client = BeatportClient(c_key, c_secret)
|
||||
try:
|
||||
url = auth_client.get_authorize_url()
|
||||
except AUTH_ERRORS as e:
|
||||
self._log.debug("authentication error: {0}", e)
|
||||
self._log.debug("authentication error: {}", e)
|
||||
raise beets.ui.UserError("communication with Beatport failed")
|
||||
|
||||
beets.ui.print_("To authenticate with Beatport, visit:")
|
||||
|
|
@ -333,69 +371,54 @@ class BeatportPlugin(BeetsPlugin):
|
|||
try:
|
||||
token, secret = auth_client.get_access_token(data)
|
||||
except AUTH_ERRORS as e:
|
||||
self._log.debug("authentication error: {0}", e)
|
||||
self._log.debug("authentication error: {}", e)
|
||||
raise beets.ui.UserError("Beatport token request failed")
|
||||
|
||||
# Save the token for later use.
|
||||
self._log.debug("Beatport token {0}, secret {1}", token, secret)
|
||||
self._log.debug("Beatport token {}, secret {}", token, secret)
|
||||
with open(self._tokenfile(), "w") as f:
|
||||
json.dump({"token": token, "secret": secret}, f)
|
||||
|
||||
return token, secret
|
||||
|
||||
def _tokenfile(self):
|
||||
def _tokenfile(self) -> str:
|
||||
"""Get the path to the JSON file for storing the OAuth token."""
|
||||
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
||||
|
||||
def album_distance(self, items, album_info, mapping):
|
||||
"""Returns the Beatport source weight and the maximum source weight
|
||||
for albums.
|
||||
"""
|
||||
return get_distance(
|
||||
data_source=self.data_source, info=album_info, config=self.config
|
||||
)
|
||||
|
||||
def track_distance(self, item, track_info):
|
||||
"""Returns the Beatport source weight and the maximum source weight
|
||||
for individual tracks.
|
||||
"""
|
||||
return get_distance(
|
||||
data_source=self.data_source, info=track_info, config=self.config
|
||||
)
|
||||
|
||||
def candidates(self, items, artist, release, va_likely, extra_tags=None):
|
||||
"""Returns a list of AlbumInfo objects for beatport search results
|
||||
matching release and artist (if not various).
|
||||
"""
|
||||
def candidates(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
artist: str,
|
||||
album: str,
|
||||
va_likely: bool,
|
||||
) -> Iterator[AlbumInfo]:
|
||||
if va_likely:
|
||||
query = release
|
||||
query = album
|
||||
else:
|
||||
query = f"{artist} {release}"
|
||||
query = f"{artist} {album}"
|
||||
try:
|
||||
return self._get_releases(query)
|
||||
yield from self._get_releases(query)
|
||||
except BeatportAPIError as e:
|
||||
self._log.debug("API Error: {0} (query: {1})", e, query)
|
||||
return []
|
||||
self._log.debug("API Error: {} (query: {})", e, query)
|
||||
return
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
"""Returns a list of TrackInfo objects for beatport search results
|
||||
matching title and artist.
|
||||
"""
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
query = f"{artist} {title}"
|
||||
try:
|
||||
return self._get_tracks(query)
|
||||
except BeatportAPIError as e:
|
||||
self._log.debug("API Error: {0} (query: {1})", e, query)
|
||||
self._log.debug("API Error: {} (query: {})", e, query)
|
||||
return []
|
||||
|
||||
def album_for_id(self, release_id):
|
||||
def album_for_id(self, album_id: str):
|
||||
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
|
||||
or None if the query is not a valid ID or release is not found.
|
||||
"""
|
||||
self._log.debug("Searching for release {0}", release_id)
|
||||
self._log.debug("Searching for release {}", album_id)
|
||||
|
||||
release_id = self._get_id("album", release_id, self.id_regex)
|
||||
if release_id is None:
|
||||
if not (release_id := self._extract_id(album_id)):
|
||||
self._log.debug("Not a valid Beatport release ID.")
|
||||
return None
|
||||
|
||||
|
|
@ -404,11 +427,12 @@ class BeatportPlugin(BeetsPlugin):
|
|||
return self._get_album_info(release)
|
||||
return None
|
||||
|
||||
def track_for_id(self, track_id):
|
||||
def track_for_id(self, track_id: str):
|
||||
"""Fetches a track by its Beatport ID and returns a TrackInfo object
|
||||
or None if the track is not a valid Beatport ID or track is not found.
|
||||
"""
|
||||
self._log.debug("Searching for track {0}", track_id)
|
||||
self._log.debug("Searching for track {}", track_id)
|
||||
# TODO: move to extractor
|
||||
match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id)
|
||||
if not match:
|
||||
self._log.debug("Not a valid Beatport track ID.")
|
||||
|
|
@ -418,7 +442,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
return self._get_track_info(bp_track)
|
||||
return None
|
||||
|
||||
def _get_releases(self, query):
|
||||
def _get_releases(self, query: str) -> Iterator[AlbumInfo]:
|
||||
"""Returns a list of AlbumInfo objects for a beatport search query."""
|
||||
# Strip non-word characters from query. Things like "!" and "-" can
|
||||
# cause a query to return no results, even if they match the artist or
|
||||
|
|
@ -428,16 +452,22 @@ class BeatportPlugin(BeetsPlugin):
|
|||
# Strip medium information from query, Things like "CD1" and "disk 1"
|
||||
# can also negate an otherwise positive result.
|
||||
query = re.sub(r"\b(CD|disc)\s*\d+", "", query, flags=re.I)
|
||||
albums = [self._get_album_info(x) for x in self.client.search(query)]
|
||||
return albums
|
||||
for beatport_release in self.client.search(query, "release"):
|
||||
if beatport_release is None:
|
||||
continue
|
||||
yield self._get_album_info(beatport_release)
|
||||
|
||||
def _get_album_info(self, release):
|
||||
def _get_album_info(self, release: BeatportRelease) -> AlbumInfo:
|
||||
"""Returns an AlbumInfo object for a Beatport Release object."""
|
||||
va = len(release.artists) > 3
|
||||
va = release.artists is not None and len(release.artists) > 3
|
||||
artist, artist_id = self._get_artist(release.artists)
|
||||
if va:
|
||||
artist = "Various Artists"
|
||||
tracks = [self._get_track_info(x) for x in release.tracks]
|
||||
tracks: list[TrackInfo] = []
|
||||
if release.tracks is not None:
|
||||
tracks = [self._get_track_info(x) for x in release.tracks]
|
||||
|
||||
release_date = release.release_date
|
||||
|
||||
return AlbumInfo(
|
||||
album=release.name,
|
||||
|
|
@ -448,18 +478,18 @@ class BeatportPlugin(BeetsPlugin):
|
|||
tracks=tracks,
|
||||
albumtype=release.category,
|
||||
va=va,
|
||||
year=release.release_date.year,
|
||||
month=release.release_date.month,
|
||||
day=release.release_date.day,
|
||||
label=release.label_name,
|
||||
catalognum=release.catalog_number,
|
||||
media="Digital",
|
||||
data_source=self.data_source,
|
||||
data_url=release.url,
|
||||
genre=release.genre,
|
||||
year=release_date.year if release_date else None,
|
||||
month=release_date.month if release_date else None,
|
||||
day=release_date.day if release_date else None,
|
||||
)
|
||||
|
||||
def _get_track_info(self, track):
|
||||
def _get_track_info(self, track: BeatportTrack) -> TrackInfo:
|
||||
"""Returns a TrackInfo object for a Beatport Track object."""
|
||||
title = track.name
|
||||
if track.mix_name != "Original Mix":
|
||||
|
|
@ -485,9 +515,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
"""Returns an artist string (all artists) and an artist_id (the main
|
||||
artist) for a list of Beatport release or track artists.
|
||||
"""
|
||||
return MetadataSourcePlugin.get_artist(
|
||||
artists=artists, id_key=0, name_key=1
|
||||
)
|
||||
return self.get_artist(artists=artists, id_key=0, name_key=1)
|
||||
|
||||
def _get_tracks(self, query):
|
||||
"""Returns a list of TrackInfo objects for a Beatport query."""
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@
|
|||
import cProfile
|
||||
import timeit
|
||||
|
||||
from beets import importer, library, plugins, ui, vfs
|
||||
from beets import importer, library, plugins, ui
|
||||
from beets.autotag import match
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util.functemplate import Template
|
||||
from beetsplug._utils import vfs
|
||||
|
||||
|
||||
def aunique_benchmark(lib, prof):
|
||||
|
|
@ -125,7 +126,7 @@ class BenchmarkPlugin(BeetsPlugin):
|
|||
"-i", "--id", default=None, help="album ID to match against"
|
||||
)
|
||||
match_bench_cmd.func = lambda lib, opts, args: match_benchmark(
|
||||
lib, opts.profile, ui.decargs(args), opts.id
|
||||
lib, opts.profile, args, opts.id
|
||||
)
|
||||
|
||||
return [aunique_bench_cmd, match_bench_cmd]
|
||||
|
|
|
|||
|
|
@ -30,18 +30,30 @@ from typing import TYPE_CHECKING
|
|||
|
||||
import beets
|
||||
import beets.ui
|
||||
from beets import dbcore, vfs
|
||||
from beets import dbcore, logging
|
||||
from beets.library import Item
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import bluelet
|
||||
from beets.util import as_string, bluelet
|
||||
from beetsplug._utils import vfs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.dbcore.query import Query
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
try:
|
||||
from . import gstplayer
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Gstreamer Python bindings not found."
|
||||
' Install "gstreamer1.0" and "python-gi" or similar package to use BPD.'
|
||||
) from e
|
||||
|
||||
PROTOCOL_VERSION = "0.16.0"
|
||||
BUFSIZE = 1024
|
||||
|
||||
HELLO = "OK MPD %s" % PROTOCOL_VERSION
|
||||
HELLO = f"OK MPD {PROTOCOL_VERSION}"
|
||||
CLIST_BEGIN = "command_list_begin"
|
||||
CLIST_VERBOSE_BEGIN = "command_list_ok_begin"
|
||||
CLIST_END = "command_list_end"
|
||||
|
|
@ -94,11 +106,6 @@ SUBSYSTEMS = [
|
|||
]
|
||||
|
||||
|
||||
# Gstreamer import error.
|
||||
class NoGstreamerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Error-handling, exceptions, parameter parsing.
|
||||
|
||||
|
||||
|
|
@ -276,7 +283,7 @@ class BaseServer:
|
|||
if not self.ctrl_sock:
|
||||
self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port))
|
||||
self.ctrl_sock.sendall((message + "\n").encode("utf-8"))
|
||||
self.ctrl_sock.sendall((f"{message}\n").encode())
|
||||
|
||||
def _send_event(self, event):
|
||||
"""Notify subscribed connections of an event."""
|
||||
|
|
@ -370,13 +377,13 @@ class BaseServer:
|
|||
if self.password and not conn.authenticated:
|
||||
# Not authenticated. Show limited list of commands.
|
||||
for cmd in SAFE_COMMANDS:
|
||||
yield "command: " + cmd
|
||||
yield f"command: {cmd}"
|
||||
|
||||
else:
|
||||
# Authenticated. Show all commands.
|
||||
for func in dir(self):
|
||||
if func.startswith("cmd_"):
|
||||
yield "command: " + func[4:]
|
||||
yield f"command: {func[4:]}"
|
||||
|
||||
def cmd_notcommands(self, conn):
|
||||
"""Lists all unavailable commands."""
|
||||
|
|
@ -386,7 +393,7 @@ class BaseServer:
|
|||
if func.startswith("cmd_"):
|
||||
cmd = func[4:]
|
||||
if cmd not in SAFE_COMMANDS:
|
||||
yield "command: " + cmd
|
||||
yield f"command: {cmd}"
|
||||
|
||||
else:
|
||||
# Authenticated. No commands are unavailable.
|
||||
|
|
@ -400,22 +407,22 @@ class BaseServer:
|
|||
playlist, playlistlength, and xfade.
|
||||
"""
|
||||
yield (
|
||||
"repeat: " + str(int(self.repeat)),
|
||||
"random: " + str(int(self.random)),
|
||||
"consume: " + str(int(self.consume)),
|
||||
"single: " + str(int(self.single)),
|
||||
"playlist: " + str(self.playlist_version),
|
||||
"playlistlength: " + str(len(self.playlist)),
|
||||
"mixrampdb: " + str(self.mixrampdb),
|
||||
f"repeat: {int(self.repeat)}",
|
||||
f"random: {int(self.random)}",
|
||||
f"consume: {int(self.consume)}",
|
||||
f"single: {int(self.single)}",
|
||||
f"playlist: {self.playlist_version}",
|
||||
f"playlistlength: {len(self.playlist)}",
|
||||
f"mixrampdb: {self.mixrampdb}",
|
||||
)
|
||||
|
||||
if self.volume > 0:
|
||||
yield "volume: " + str(self.volume)
|
||||
yield f"volume: {self.volume}"
|
||||
|
||||
if not math.isnan(self.mixrampdelay):
|
||||
yield "mixrampdelay: " + str(self.mixrampdelay)
|
||||
yield f"mixrampdelay: {self.mixrampdelay}"
|
||||
if self.crossfade > 0:
|
||||
yield "xfade: " + str(self.crossfade)
|
||||
yield f"xfade: {self.crossfade}"
|
||||
|
||||
if self.current_index == -1:
|
||||
state = "stop"
|
||||
|
|
@ -423,20 +430,20 @@ class BaseServer:
|
|||
state = "pause"
|
||||
else:
|
||||
state = "play"
|
||||
yield "state: " + state
|
||||
yield f"state: {state}"
|
||||
|
||||
if self.current_index != -1: # i.e., paused or playing
|
||||
current_id = self._item_id(self.playlist[self.current_index])
|
||||
yield "song: " + str(self.current_index)
|
||||
yield "songid: " + str(current_id)
|
||||
yield f"song: {self.current_index}"
|
||||
yield f"songid: {current_id}"
|
||||
if len(self.playlist) > self.current_index + 1:
|
||||
# If there's a next song, report its index too.
|
||||
next_id = self._item_id(self.playlist[self.current_index + 1])
|
||||
yield "nextsong: " + str(self.current_index + 1)
|
||||
yield "nextsongid: " + str(next_id)
|
||||
yield f"nextsong: {self.current_index + 1}"
|
||||
yield f"nextsongid: {next_id}"
|
||||
|
||||
if self.error:
|
||||
yield "error: " + self.error
|
||||
yield f"error: {self.error}"
|
||||
|
||||
def cmd_clearerror(self, conn):
|
||||
"""Removes the persistent error state of the server. This
|
||||
|
|
@ -516,7 +523,7 @@ class BaseServer:
|
|||
|
||||
def cmd_replay_gain_status(self, conn):
|
||||
"""Get the replaygain mode."""
|
||||
yield "replay_gain_mode: " + str(self.replay_gain_mode)
|
||||
yield f"replay_gain_mode: {self.replay_gain_mode}"
|
||||
|
||||
def cmd_clear(self, conn):
|
||||
"""Clear the playlist."""
|
||||
|
|
@ -637,8 +644,8 @@ class BaseServer:
|
|||
Also a dummy implementation.
|
||||
"""
|
||||
for idx, track in enumerate(self.playlist):
|
||||
yield "cpos: " + str(idx)
|
||||
yield "Id: " + str(track.id)
|
||||
yield f"cpos: {idx}"
|
||||
yield f"Id: {track.id}"
|
||||
|
||||
def cmd_currentsong(self, conn):
|
||||
"""Sends information about the currently-playing song."""
|
||||
|
|
@ -753,11 +760,11 @@ class Connection:
|
|||
"""Create a new connection for the accepted socket `client`."""
|
||||
self.server = server
|
||||
self.sock = sock
|
||||
self.address = "{}:{}".format(*sock.sock.getpeername())
|
||||
self.address = ":".join(map(str, sock.sock.getpeername()))
|
||||
|
||||
def debug(self, message, kind=" "):
|
||||
"""Log a debug message about this connection."""
|
||||
self.server._log.debug("{}[{}]: {}", kind, self.address, message)
|
||||
self.server._log.debug("{}[{.address}]: {}", kind, self, message)
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
|
|
@ -893,9 +900,7 @@ class MPDConnection(Connection):
|
|||
return
|
||||
except BPDIdleError as e:
|
||||
self.idle_subscriptions = e.subsystems
|
||||
self.debug(
|
||||
"awaiting: {}".format(" ".join(e.subsystems)), kind="z"
|
||||
)
|
||||
self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z")
|
||||
yield bluelet.call(self.server.dispatch_events())
|
||||
|
||||
|
||||
|
|
@ -907,7 +912,7 @@ class ControlConnection(Connection):
|
|||
super().__init__(server, sock)
|
||||
|
||||
def debug(self, message, kind=" "):
|
||||
self.server._log.debug("CTRL {}[{}]: {}", kind, self.address, message)
|
||||
self.server._log.debug("CTRL {}[{.address}]: {}", kind, self, message)
|
||||
|
||||
def run(self):
|
||||
"""Listen for control commands and delegate to `ctrl_*` methods."""
|
||||
|
|
@ -927,7 +932,7 @@ class ControlConnection(Connection):
|
|||
func = command.delegate("ctrl_", self)
|
||||
yield bluelet.call(func(*command.args))
|
||||
except (AttributeError, TypeError) as e:
|
||||
yield self.send("ERROR: {}".format(e.args[0]))
|
||||
yield self.send(f"ERROR: {e.args[0]}")
|
||||
except Exception:
|
||||
yield self.send(
|
||||
["ERROR: server error", traceback.format_exc().rstrip()]
|
||||
|
|
@ -986,7 +991,7 @@ class Command:
|
|||
of arguments.
|
||||
"""
|
||||
# Attempt to get correct command function.
|
||||
func_name = prefix + self.name
|
||||
func_name = f"{prefix}{self.name}"
|
||||
if not hasattr(target, func_name):
|
||||
raise AttributeError(f'unknown command "{self.name}"')
|
||||
func = getattr(target, func_name)
|
||||
|
|
@ -1005,7 +1010,7 @@ class Command:
|
|||
# If the command accepts a variable number of arguments skip the check.
|
||||
if wrong_num and not argspec.varargs:
|
||||
raise TypeError(
|
||||
'wrong number of arguments for "{}"'.format(self.name),
|
||||
f'wrong number of arguments for "{self.name}"',
|
||||
self.name,
|
||||
)
|
||||
|
||||
|
|
@ -1099,23 +1104,13 @@ class Server(BaseServer):
|
|||
"""
|
||||
|
||||
def __init__(self, library, host, port, password, ctrl_port, log):
|
||||
try:
|
||||
from beetsplug.bpd import gstplayer
|
||||
except ImportError as e:
|
||||
# This is a little hacky, but it's the best I know for now.
|
||||
if e.args[0].endswith(" gst"):
|
||||
raise NoGstreamerError()
|
||||
else:
|
||||
raise
|
||||
log.info("Starting server...")
|
||||
super().__init__(host, port, password, ctrl_port, log)
|
||||
self.lib = library
|
||||
self.player = gstplayer.GstPlayer(self.play_finished)
|
||||
self.cmd_update(None)
|
||||
log.info("Server ready and listening on {}:{}".format(host, port))
|
||||
log.debug(
|
||||
"Listening for control signals on {}:{}".format(host, ctrl_port)
|
||||
)
|
||||
log.info("Server ready and listening on {}:{}", host, port)
|
||||
log.debug("Listening for control signals on {}:{}", host, ctrl_port)
|
||||
|
||||
def run(self):
|
||||
self.player.run()
|
||||
|
|
@ -1130,23 +1125,21 @@ class Server(BaseServer):
|
|||
|
||||
def _item_info(self, item):
|
||||
info_lines = [
|
||||
"file: " + item.destination(fragment=True),
|
||||
"Time: " + str(int(item.length)),
|
||||
"duration: " + f"{item.length:.3f}",
|
||||
"Id: " + str(item.id),
|
||||
f"file: {as_string(item.destination(relative_to_libdir=True))}",
|
||||
f"Time: {int(item.length)}",
|
||||
"duration: {item.length:.3f}",
|
||||
f"Id: {item.id}",
|
||||
]
|
||||
|
||||
try:
|
||||
pos = self._id_to_index(item.id)
|
||||
info_lines.append("Pos: " + str(pos))
|
||||
info_lines.append(f"Pos: {pos}")
|
||||
except ArgumentNotFoundError:
|
||||
# Don't include position if not in playlist.
|
||||
pass
|
||||
|
||||
for tagtype, field in self.tagtype_map.items():
|
||||
info_lines.append(
|
||||
"{}: {}".format(tagtype, str(getattr(item, field)))
|
||||
)
|
||||
info_lines.append(f"{tagtype}: {getattr(item, field)}")
|
||||
|
||||
return info_lines
|
||||
|
||||
|
|
@ -1209,7 +1202,7 @@ class Server(BaseServer):
|
|||
|
||||
def _path_join(self, p1, p2):
|
||||
"""Smashes together two BPD paths."""
|
||||
out = p1 + "/" + p2
|
||||
out = f"{p1}/{p2}"
|
||||
return out.replace("//", "/").replace("//", "/")
|
||||
|
||||
def cmd_lsinfo(self, conn, path="/"):
|
||||
|
|
@ -1227,7 +1220,7 @@ class Server(BaseServer):
|
|||
if dirpath.startswith("/"):
|
||||
# Strip leading slash (libmpc rejects this).
|
||||
dirpath = dirpath[1:]
|
||||
yield "directory: %s" % dirpath
|
||||
yield f"directory: {dirpath}"
|
||||
|
||||
def _listall(self, basepath, node, info=False):
|
||||
"""Helper function for recursive listing. If info, show
|
||||
|
|
@ -1239,7 +1232,7 @@ class Server(BaseServer):
|
|||
item = self.lib.get_item(node)
|
||||
yield self._item_info(item)
|
||||
else:
|
||||
yield "file: " + basepath
|
||||
yield f"file: {basepath}"
|
||||
else:
|
||||
# List a directory. Recurse into both directories and files.
|
||||
for name, itemid in sorted(node.files.items()):
|
||||
|
|
@ -1248,7 +1241,7 @@ class Server(BaseServer):
|
|||
yield from self._listall(newpath, itemid, info)
|
||||
for name, subdir in sorted(node.dirs.items()):
|
||||
newpath = self._path_join(basepath, name)
|
||||
yield "directory: " + newpath
|
||||
yield f"directory: {newpath}"
|
||||
yield from self._listall(newpath, subdir, info)
|
||||
|
||||
def cmd_listall(self, conn, path="/"):
|
||||
|
|
@ -1282,7 +1275,7 @@ class Server(BaseServer):
|
|||
for item in self._all_items(self._resolve_path(path)):
|
||||
self.playlist.append(item)
|
||||
if send_id:
|
||||
yield "Id: " + str(item.id)
|
||||
yield f"Id: {item.id}"
|
||||
self.playlist_version += 1
|
||||
self._send_event("playlist")
|
||||
|
||||
|
|
@ -1304,20 +1297,13 @@ class Server(BaseServer):
|
|||
item = self.playlist[self.current_index]
|
||||
|
||||
yield (
|
||||
"bitrate: " + str(item.bitrate / 1000),
|
||||
"audio: {}:{}:{}".format(
|
||||
str(item.samplerate),
|
||||
str(item.bitdepth),
|
||||
str(item.channels),
|
||||
),
|
||||
f"bitrate: {item.bitrate / 1000}",
|
||||
f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}",
|
||||
)
|
||||
|
||||
(pos, total) = self.player.time()
|
||||
yield (
|
||||
"time: {}:{}".format(
|
||||
str(int(pos)),
|
||||
str(int(total)),
|
||||
),
|
||||
f"time: {int(pos)}:{int(total)}",
|
||||
"elapsed: " + f"{pos:.3f}",
|
||||
"duration: " + f"{total:.3f}",
|
||||
)
|
||||
|
|
@ -1337,13 +1323,13 @@ class Server(BaseServer):
|
|||
artists, albums, songs, totaltime = tx.query(statement)[0]
|
||||
|
||||
yield (
|
||||
"artists: " + str(artists),
|
||||
"albums: " + str(albums),
|
||||
"songs: " + str(songs),
|
||||
"uptime: " + str(int(time.time() - self.startup_time)),
|
||||
"playtime: " + "0", # Missing.
|
||||
"db_playtime: " + str(int(totaltime)),
|
||||
"db_update: " + str(int(self.updated_time)),
|
||||
f"artists: {artists}",
|
||||
f"albums: {albums}",
|
||||
f"songs: {songs}",
|
||||
f"uptime: {int(time.time() - self.startup_time)}",
|
||||
"playtime: 0", # Missing.
|
||||
f"db_playtime: {int(totaltime)}",
|
||||
f"db_update: {int(self.updated_time)}",
|
||||
)
|
||||
|
||||
def cmd_decoders(self, conn):
|
||||
|
|
@ -1385,7 +1371,7 @@ class Server(BaseServer):
|
|||
searching.
|
||||
"""
|
||||
for tag in self.tagtype_map:
|
||||
yield "tagtype: " + tag
|
||||
yield f"tagtype: {tag}"
|
||||
|
||||
def _tagtype_lookup(self, tag):
|
||||
"""Uses `tagtype_map` to look up the beets column name for an
|
||||
|
|
@ -1460,12 +1446,9 @@ class Server(BaseServer):
|
|||
|
||||
clause, subvals = query.clause()
|
||||
statement = (
|
||||
"SELECT DISTINCT "
|
||||
+ show_key
|
||||
+ " FROM items WHERE "
|
||||
+ clause
|
||||
+ " ORDER BY "
|
||||
+ show_key
|
||||
f"SELECT DISTINCT {show_key}"
|
||||
f" FROM items WHERE {clause}"
|
||||
f" ORDER BY {show_key}"
|
||||
)
|
||||
self._log.debug(statement)
|
||||
with self.lib.transaction() as tx:
|
||||
|
|
@ -1475,7 +1458,7 @@ class Server(BaseServer):
|
|||
if not row[0]:
|
||||
# Skip any empty values of the field.
|
||||
continue
|
||||
yield show_tag_canon + ": " + str(row[0])
|
||||
yield f"{show_tag_canon}: {row[0]}"
|
||||
|
||||
def cmd_count(self, conn, tag, value):
|
||||
"""Returns the number and total time of songs matching the
|
||||
|
|
@ -1489,8 +1472,8 @@ class Server(BaseServer):
|
|||
):
|
||||
songs += 1
|
||||
playtime += item.length
|
||||
yield "songs: " + str(songs)
|
||||
yield "playtime: " + str(int(playtime))
|
||||
yield f"songs: {songs}"
|
||||
yield f"playtime: {int(playtime)}"
|
||||
|
||||
# Persistent playlist manipulation. In MPD this is an optional feature so
|
||||
# these dummy implementations match MPD's behaviour with the feature off.
|
||||
|
|
@ -1616,16 +1599,9 @@ class BPDPlugin(BeetsPlugin):
|
|||
|
||||
def start_bpd(self, lib, host, port, password, volume, ctrl_port):
|
||||
"""Starts a BPD server."""
|
||||
try:
|
||||
server = Server(lib, host, port, password, ctrl_port, self._log)
|
||||
server.cmd_setvol(None, volume)
|
||||
server.run()
|
||||
except NoGstreamerError:
|
||||
self._log.error("Gstreamer Python bindings not found.")
|
||||
self._log.error(
|
||||
'Install "gstreamer1.0" and "python-gi"'
|
||||
"or similar package to use BPD."
|
||||
)
|
||||
server = Server(lib, host, port, password, ctrl_port, self._log)
|
||||
server.cmd_setvol(None, volume)
|
||||
server.run()
|
||||
|
||||
def commands(self):
|
||||
cmd = beets.ui.Subcommand(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,16 @@ import gi
|
|||
|
||||
from beets import ui
|
||||
|
||||
gi.require_version("Gst", "1.0")
|
||||
try:
|
||||
gi.require_version("Gst", "1.0")
|
||||
except ValueError as e:
|
||||
# on some scenarios, gi may be importable, but we get a ValueError when
|
||||
# trying to specify the required version. This is problematic in the test
|
||||
# suite where test_bpd.py has a call to
|
||||
# pytest.importorskip("beetsplug.bpd"). Re-raising as an ImportError
|
||||
# makes it so the test collector functions as inteded.
|
||||
raise ImportError from e
|
||||
|
||||
from gi.repository import GLib, Gst # noqa: E402
|
||||
|
||||
Gst.init(None)
|
||||
|
|
@ -129,7 +138,7 @@ class GstPlayer:
|
|||
self.player.set_state(Gst.State.NULL)
|
||||
if isinstance(path, str):
|
||||
path = path.encode("utf-8")
|
||||
uri = "file://" + urllib.parse.quote(path)
|
||||
uri = f"file://{urllib.parse.quote(path)}"
|
||||
self.player.set_property("uri", uri)
|
||||
self.player.set_state(Gst.State.PLAYING)
|
||||
self.playing = True
|
||||
|
|
|
|||
|
|
@ -57,15 +57,14 @@ class BPMPlugin(BeetsPlugin):
|
|||
def commands(self):
|
||||
cmd = ui.Subcommand(
|
||||
"bpm",
|
||||
help="determine bpm of a song by pressing " "a key to the rhythm",
|
||||
help="determine bpm of a song by pressing a key to the rhythm",
|
||||
)
|
||||
cmd.func = self.command
|
||||
return [cmd]
|
||||
|
||||
def command(self, lib, opts, args):
|
||||
items = lib.items(ui.decargs(args))
|
||||
write = ui.should_write()
|
||||
self.get_bpm(items, write)
|
||||
self.get_bpm(lib.items(args), write)
|
||||
|
||||
def get_bpm(self, items, write=False):
|
||||
overwrite = self.config["overwrite"].get(bool)
|
||||
|
|
@ -74,12 +73,12 @@ class BPMPlugin(BeetsPlugin):
|
|||
|
||||
item = items[0]
|
||||
if item["bpm"]:
|
||||
self._log.info("Found bpm {0}", item["bpm"])
|
||||
self._log.info("Found bpm {}", item["bpm"])
|
||||
if not overwrite:
|
||||
return
|
||||
|
||||
self._log.info(
|
||||
"Press Enter {0} times to the rhythm or Ctrl-D " "to exit",
|
||||
"Press Enter {} times to the rhythm or Ctrl-D to exit",
|
||||
self.config["max_strokes"].get(int),
|
||||
)
|
||||
new_bpm = bpm(self.config["max_strokes"].get(int))
|
||||
|
|
@ -87,4 +86,4 @@ class BPMPlugin(BeetsPlugin):
|
|||
if write:
|
||||
item.try_write()
|
||||
item.store()
|
||||
self._log.info("Added new bpm {0}", item["bpm"])
|
||||
self._log.info("Added new bpm {}", item["bpm"])
|
||||
|
|
|
|||
|
|
@ -65,10 +65,9 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
move = ui.should_move(opts.move)
|
||||
pretend = opts.pretend
|
||||
write = ui.should_write(opts.write)
|
||||
query = ui.decargs(args)
|
||||
|
||||
self.singletons(lib, query, move, pretend, write)
|
||||
self.albums(lib, query, move, pretend, write)
|
||||
self.singletons(lib, args, move, pretend, write)
|
||||
self.albums(lib, args, move, pretend, write)
|
||||
|
||||
def singletons(self, lib, query, move, pretend, write):
|
||||
"""Retrieve and apply info from the autotagger for items matched by
|
||||
|
|
@ -83,8 +82,8 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
|
||||
if not self.is_beatport_track(item):
|
||||
self._log.info(
|
||||
"Skipping non-{} singleton: {}",
|
||||
self.beatport_plugin.data_source,
|
||||
"Skipping non-{.beatport_plugin.data_source} singleton: {}",
|
||||
self,
|
||||
item,
|
||||
)
|
||||
continue
|
||||
|
|
@ -108,8 +107,8 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
return False
|
||||
if not album.mb_albumid.isnumeric():
|
||||
self._log.info(
|
||||
"Skipping album with invalid {} ID: {}",
|
||||
self.beatport_plugin.data_source,
|
||||
"Skipping album with invalid {.beatport_plugin.data_source} ID: {}",
|
||||
self,
|
||||
album,
|
||||
)
|
||||
return False
|
||||
|
|
@ -118,8 +117,8 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
return items
|
||||
if not all(self.is_beatport_track(item) for item in items):
|
||||
self._log.info(
|
||||
"Skipping non-{} release: {}",
|
||||
self.beatport_plugin.data_source,
|
||||
"Skipping non-{.beatport_plugin.data_source} release: {}",
|
||||
self,
|
||||
album,
|
||||
)
|
||||
return False
|
||||
|
|
@ -140,9 +139,7 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
|
||||
if not albuminfo:
|
||||
self._log.info(
|
||||
"Release ID {} not found for album {}",
|
||||
album.mb_albumid,
|
||||
album,
|
||||
"Release ID {0.mb_albumid} not found for album {0}", album
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -152,14 +149,14 @@ class BPSyncPlugin(BeetsPlugin):
|
|||
library_trackid_to_item = {
|
||||
int(item.mb_trackid): item for item in items
|
||||
}
|
||||
item_to_trackinfo = {
|
||||
item: beatport_trackid_to_trackinfo[track_id]
|
||||
item_info_pairs = [
|
||||
(item, beatport_trackid_to_trackinfo[track_id])
|
||||
for track_id, item in library_trackid_to_item.items()
|
||||
}
|
||||
]
|
||||
|
||||
self._log.info("applying changes to {}", album)
|
||||
with lib.transaction():
|
||||
autotag.apply_metadata(albuminfo, item_to_trackinfo)
|
||||
autotag.apply_metadata(albuminfo, item_info_pairs)
|
||||
changed = False
|
||||
# Find any changed item to apply Beatport changes to album.
|
||||
any_changed_item = items[0]
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def span_from_str(span_str):
|
|||
def normalize_year(d, yearfrom):
|
||||
"""Convert string to a 4 digits year"""
|
||||
if yearfrom < 100:
|
||||
raise BucketError("%d must be expressed on 4 digits" % yearfrom)
|
||||
raise BucketError(f"{yearfrom} must be expressed on 4 digits")
|
||||
|
||||
# if two digits only, pick closest year that ends by these two
|
||||
# digits starting from yearfrom
|
||||
|
|
@ -55,14 +55,13 @@ def span_from_str(span_str):
|
|||
years = [int(x) for x in re.findall(r"\d+", span_str)]
|
||||
if not years:
|
||||
raise ui.UserError(
|
||||
"invalid range defined for year bucket '%s': no "
|
||||
"year found" % span_str
|
||||
f"invalid range defined for year bucket {span_str!r}: no year found"
|
||||
)
|
||||
try:
|
||||
years = [normalize_year(x, years[0]) for x in years]
|
||||
except BucketError as exc:
|
||||
raise ui.UserError(
|
||||
"invalid range defined for year bucket '%s': %s" % (span_str, exc)
|
||||
f"invalid range defined for year bucket {span_str!r}: {exc}"
|
||||
)
|
||||
|
||||
res = {"from": years[0], "str": span_str}
|
||||
|
|
@ -125,22 +124,19 @@ def str2fmt(s):
|
|||
"fromnchars": len(m.group("fromyear")),
|
||||
"tonchars": len(m.group("toyear")),
|
||||
}
|
||||
res["fmt"] = "{}%s{}{}{}".format(
|
||||
m.group("bef"),
|
||||
m.group("sep"),
|
||||
"%s" if res["tonchars"] else "",
|
||||
m.group("after"),
|
||||
res["fmt"] = (
|
||||
f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}"
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def format_span(fmt, yearfrom, yearto, fromnchars, tonchars):
|
||||
"""Return a span string representation."""
|
||||
args = str(yearfrom)[-fromnchars:]
|
||||
args = [str(yearfrom)[-fromnchars:]]
|
||||
if tonchars:
|
||||
args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:])
|
||||
args.append(str(yearto)[-tonchars:])
|
||||
|
||||
return fmt % args
|
||||
return fmt.format(*args)
|
||||
|
||||
|
||||
def extract_modes(spans):
|
||||
|
|
@ -169,14 +165,12 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
|
|||
else:
|
||||
raise ui.UserError(
|
||||
"invalid range defined for alpha bucket "
|
||||
"'%s': no alphanumeric character found" % elem
|
||||
f"'{elem}': no alphanumeric character found"
|
||||
)
|
||||
spans.append(
|
||||
re.compile(
|
||||
"^["
|
||||
+ ASCII_DIGITS[begin_index : end_index + 1]
|
||||
+ ASCII_DIGITS[begin_index : end_index + 1].upper()
|
||||
+ "]"
|
||||
rf"^[{ASCII_DIGITS[begin_index : end_index + 1]}]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
return spans
|
||||
|
|
|
|||
|
|
@ -18,13 +18,17 @@ autotagger. Requires the pyacoustid library.
|
|||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from collections.abc import Iterable
|
||||
from functools import cached_property, partial
|
||||
|
||||
import acoustid
|
||||
import confuse
|
||||
|
||||
from beets import config, plugins, ui, util
|
||||
from beets.autotag import hooks
|
||||
from beets import config, ui, util
|
||||
from beets.autotag.distance import Distance
|
||||
from beets.autotag.hooks import TrackInfo
|
||||
from beets.metadata_plugins import MetadataSourcePlugin
|
||||
from beetsplug.musicbrainz import MusicBrainzPlugin
|
||||
|
||||
API_KEY = "1vOwZtEn"
|
||||
SCORE_THRESH = 0.5
|
||||
|
|
@ -86,7 +90,7 @@ def acoustid_match(log, path):
|
|||
duration, fp = acoustid.fingerprint_file(util.syspath(path))
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.error(
|
||||
"fingerprinting of {0} failed: {1}",
|
||||
"fingerprinting of {} failed: {}",
|
||||
util.displayable_path(repr(path)),
|
||||
exc,
|
||||
)
|
||||
|
|
@ -94,15 +98,17 @@ def acoustid_match(log, path):
|
|||
fp = fp.decode()
|
||||
_fingerprints[path] = fp
|
||||
try:
|
||||
res = acoustid.lookup(API_KEY, fp, duration, meta="recordings releases")
|
||||
res = acoustid.lookup(
|
||||
API_KEY, fp, duration, meta="recordings releases", timeout=10
|
||||
)
|
||||
except acoustid.AcoustidError as exc:
|
||||
log.debug(
|
||||
"fingerprint matching {0} failed: {1}",
|
||||
"fingerprint matching {} failed: {}",
|
||||
util.displayable_path(repr(path)),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path)))
|
||||
log.debug("chroma: fingerprinted {}", util.displayable_path(repr(path)))
|
||||
|
||||
# Ensure the response is usable and parse it.
|
||||
if res["status"] != "ok" or not res.get("results"):
|
||||
|
|
@ -140,7 +146,7 @@ def acoustid_match(log, path):
|
|||
release_ids = [rel["id"] for rel in releases]
|
||||
|
||||
log.debug(
|
||||
"matched recordings {0} on releases {1}", recording_ids, release_ids
|
||||
"matched recordings {} on releases {}", recording_ids, release_ids
|
||||
)
|
||||
_matches[path] = recording_ids, release_ids
|
||||
|
||||
|
|
@ -167,10 +173,9 @@ def _all_releases(items):
|
|||
yield release_id
|
||||
|
||||
|
||||
class AcoustidPlugin(plugins.BeetsPlugin):
|
||||
class AcoustidPlugin(MetadataSourcePlugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.config.add(
|
||||
{
|
||||
"auto": True,
|
||||
|
|
@ -182,11 +187,15 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
self.register_listener("import_task_start", self.fingerprint_task)
|
||||
self.register_listener("import_task_apply", apply_acoustid_metadata)
|
||||
|
||||
@cached_property
|
||||
def mb(self) -> MusicBrainzPlugin:
|
||||
return MusicBrainzPlugin()
|
||||
|
||||
def fingerprint_task(self, task, session):
|
||||
return fingerprint_task(self._log, task, session)
|
||||
|
||||
def track_distance(self, item, info):
|
||||
dist = hooks.Distance()
|
||||
dist = Distance()
|
||||
if item.path not in _matches or not info.track_id:
|
||||
# Match failed or no track ID.
|
||||
return dist
|
||||
|
|
@ -195,29 +204,37 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
dist.add_expr("track_id", info.track_id not in recording_ids)
|
||||
return dist
|
||||
|
||||
def candidates(self, items, artist, album, va_likely, extra_tags=None):
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
albums = []
|
||||
for relid in prefix(_all_releases(items), MAX_RELEASES):
|
||||
album = hooks.album_for_mbid(relid)
|
||||
album = self.mb.album_for_id(relid)
|
||||
if album:
|
||||
albums.append(album)
|
||||
|
||||
self._log.debug("acoustid album candidates: {0}", len(albums))
|
||||
self._log.debug("acoustid album candidates: {}", len(albums))
|
||||
return albums
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]:
|
||||
if item.path not in _matches:
|
||||
return []
|
||||
|
||||
recording_ids, _ = _matches[item.path]
|
||||
tracks = []
|
||||
for recording_id in prefix(recording_ids, MAX_RECORDINGS):
|
||||
track = hooks.track_for_mbid(recording_id)
|
||||
track = self.mb.track_for_id(recording_id)
|
||||
if track:
|
||||
tracks.append(track)
|
||||
self._log.debug("acoustid item candidates: {0}", len(tracks))
|
||||
self._log.debug("acoustid item candidates: {}", len(tracks))
|
||||
return tracks
|
||||
|
||||
def album_for_id(self, *args, **kwargs):
|
||||
# Lookup by fingerprint ID does not make too much sense.
|
||||
return None
|
||||
|
||||
def track_for_id(self, *args, **kwargs):
|
||||
# Lookup by fingerprint ID does not make too much sense.
|
||||
return None
|
||||
|
||||
def commands(self):
|
||||
submit_cmd = ui.Subcommand(
|
||||
"submit", help="submit Acoustid fingerprints"
|
||||
|
|
@ -228,7 +245,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
apikey = config["acoustid"]["apikey"].as_str()
|
||||
except confuse.NotFoundError:
|
||||
raise ui.UserError("no Acoustid user API key provided")
|
||||
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
|
||||
submit_items(self._log, apikey, lib.items(args))
|
||||
|
||||
submit_cmd.func = submit_cmd_func
|
||||
|
||||
|
|
@ -237,7 +254,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
)
|
||||
|
||||
def fingerprint_cmd_func(lib, opts, args):
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
for item in lib.items(args):
|
||||
fingerprint_item(self._log, item, write=ui.should_write())
|
||||
|
||||
fingerprint_cmd.func = fingerprint_cmd_func
|
||||
|
|
@ -275,11 +292,11 @@ def submit_items(log, userkey, items, chunksize=64):
|
|||
|
||||
def submit_chunk():
|
||||
"""Submit the current accumulated fingerprint data."""
|
||||
log.info("submitting {0} fingerprints", len(data))
|
||||
log.info("submitting {} fingerprints", len(data))
|
||||
try:
|
||||
acoustid.submit(API_KEY, userkey, data)
|
||||
acoustid.submit(API_KEY, userkey, data, timeout=10)
|
||||
except acoustid.AcoustidError as exc:
|
||||
log.warning("acoustid submission error: {0}", exc)
|
||||
log.warning("acoustid submission error: {}", exc)
|
||||
del data[:]
|
||||
|
||||
for item in items:
|
||||
|
|
@ -326,31 +343,23 @@ def fingerprint_item(log, item, write=False):
|
|||
"""
|
||||
# Get a fingerprint and length for this track.
|
||||
if not item.length:
|
||||
log.info("{0}: no duration available", util.displayable_path(item.path))
|
||||
log.info("{.filepath}: no duration available", item)
|
||||
elif item.acoustid_fingerprint:
|
||||
if write:
|
||||
log.info(
|
||||
"{0}: fingerprint exists, skipping",
|
||||
util.displayable_path(item.path),
|
||||
)
|
||||
log.info("{.filepath}: fingerprint exists, skipping", item)
|
||||
else:
|
||||
log.info(
|
||||
"{0}: using existing fingerprint",
|
||||
util.displayable_path(item.path),
|
||||
)
|
||||
log.info("{.filepath}: using existing fingerprint", item)
|
||||
return item.acoustid_fingerprint
|
||||
else:
|
||||
log.info("{0}: fingerprinting", util.displayable_path(item.path))
|
||||
log.info("{.filepath}: fingerprinting", item)
|
||||
try:
|
||||
_, fp = acoustid.fingerprint_file(util.syspath(item.path))
|
||||
item.acoustid_fingerprint = fp.decode()
|
||||
if write:
|
||||
log.info(
|
||||
"{0}: writing fingerprint", util.displayable_path(item.path)
|
||||
)
|
||||
log.info("{.filepath}: writing fingerprint", item)
|
||||
item.try_write()
|
||||
if item._db:
|
||||
item.store()
|
||||
return item.acoustid_fingerprint
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.info("fingerprint generation failed: {0}", exc)
|
||||
log.info("fingerprint generation failed: {}", exc)
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ from string import Template
|
|||
import mediafile
|
||||
from confuse import ConfigTypeError, Optional
|
||||
|
||||
from beets import art, config, plugins, ui, util
|
||||
from beets import config, plugins, ui, util
|
||||
from beets.library import Item, parse_query_string
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import par_map
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beets.util.m3u import M3UFile
|
||||
from beetsplug._utils import art
|
||||
|
||||
_fs_lock = threading.Lock()
|
||||
_temp_files = [] # Keep track of temporary transcoded files for deletion.
|
||||
|
|
@ -64,9 +65,7 @@ def get_format(fmt=None):
|
|||
command = format_info["command"]
|
||||
extension = format_info.get("extension", fmt)
|
||||
except KeyError:
|
||||
raise ui.UserError(
|
||||
'convert: format {} needs the "command" field'.format(fmt)
|
||||
)
|
||||
raise ui.UserError(f'convert: format {fmt} needs the "command" field')
|
||||
except ConfigTypeError:
|
||||
command = config["convert"]["formats"][fmt].get(str)
|
||||
extension = fmt
|
||||
|
|
@ -77,8 +76,8 @@ def get_format(fmt=None):
|
|||
command = config["convert"]["command"].as_str()
|
||||
elif "opts" in keys:
|
||||
# Undocumented option for backwards compatibility with < 1.3.1.
|
||||
command = "ffmpeg -i $source -y {} $dest".format(
|
||||
config["convert"]["opts"].as_str()
|
||||
command = (
|
||||
f"ffmpeg -i $source -y {config['convert']['opts'].as_str()} $dest"
|
||||
)
|
||||
if "extension" in keys:
|
||||
extension = config["convert"]["extension"].as_str()
|
||||
|
|
@ -96,12 +95,18 @@ def in_no_convert(item: Item) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def should_transcode(item, fmt):
|
||||
def should_transcode(item, fmt, force: bool = False):
|
||||
"""Determine whether the item should be transcoded as part of
|
||||
conversion (i.e., its bitrate is high or it has the wrong format).
|
||||
|
||||
If ``force`` is True, safety checks like ``no_convert`` and
|
||||
``never_convert_lossy_files`` are ignored and the item is always
|
||||
transcoded.
|
||||
"""
|
||||
if force:
|
||||
return True
|
||||
if in_no_convert(item) or (
|
||||
config["convert"]["never_convert_lossy_files"]
|
||||
config["convert"]["never_convert_lossy_files"].get(bool)
|
||||
and item.format.lower() not in LOSSLESS_FORMATS
|
||||
):
|
||||
return False
|
||||
|
|
@ -123,20 +128,28 @@ class ConvertPlugin(BeetsPlugin):
|
|||
"threads": os.cpu_count(),
|
||||
"format": "mp3",
|
||||
"id3v23": "inherit",
|
||||
"write_metadata": True,
|
||||
"formats": {
|
||||
"aac": {
|
||||
"command": "ffmpeg -i $source -y -vn -acodec aac "
|
||||
"-aq 1 $dest",
|
||||
"command": (
|
||||
"ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest"
|
||||
),
|
||||
"extension": "m4a",
|
||||
},
|
||||
"alac": {
|
||||
"command": "ffmpeg -i $source -y -vn -acodec alac $dest",
|
||||
"command": (
|
||||
"ffmpeg -i $source -y -vn -acodec alac $dest"
|
||||
),
|
||||
"extension": "m4a",
|
||||
},
|
||||
"flac": "ffmpeg -i $source -y -vn -acodec flac $dest",
|
||||
"mp3": "ffmpeg -i $source -y -vn -aq 2 $dest",
|
||||
"opus": "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest",
|
||||
"ogg": "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest",
|
||||
"opus": (
|
||||
"ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest"
|
||||
),
|
||||
"ogg": (
|
||||
"ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest"
|
||||
),
|
||||
"wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest",
|
||||
},
|
||||
"max_bitrate": None,
|
||||
|
|
@ -171,16 +184,17 @@ class ConvertPlugin(BeetsPlugin):
|
|||
"--threads",
|
||||
action="store",
|
||||
type="int",
|
||||
help="change the number of threads, \
|
||||
defaults to maximum available processors",
|
||||
help=(
|
||||
"change the number of threads, defaults to maximum available"
|
||||
" processors"
|
||||
),
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
"-k",
|
||||
"--keep-new",
|
||||
action="store_true",
|
||||
dest="keep_new",
|
||||
help="keep only the converted \
|
||||
and move the old files",
|
||||
help="keep only the converted and move the old files",
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
"-d", "--dest", action="store", help="set the destination directory"
|
||||
|
|
@ -204,16 +218,16 @@ class ConvertPlugin(BeetsPlugin):
|
|||
"--link",
|
||||
action="store_true",
|
||||
dest="link",
|
||||
help="symlink files that do not \
|
||||
need transcoding.",
|
||||
help="symlink files that do not need transcoding.",
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
"-H",
|
||||
"--hardlink",
|
||||
action="store_true",
|
||||
dest="hardlink",
|
||||
help="hardlink files that do not \
|
||||
need transcoding. Overrides --link.",
|
||||
help=(
|
||||
"hardlink files that do not need transcoding. Overrides --link."
|
||||
),
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
"-m",
|
||||
|
|
@ -228,6 +242,16 @@ class ConvertPlugin(BeetsPlugin):
|
|||
drive, relative paths pointing to media files
|
||||
will be used.""",
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
"-F",
|
||||
"--force",
|
||||
action="store_true",
|
||||
dest="force",
|
||||
help=(
|
||||
"force transcoding. Ignores no_convert, "
|
||||
"never_convert_lossy_files, and max_bitrate"
|
||||
),
|
||||
)
|
||||
cmd.parser.add_album_option()
|
||||
cmd.func = self.convert_func
|
||||
return [cmd]
|
||||
|
|
@ -251,6 +275,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
link,
|
||||
playlist,
|
||||
force,
|
||||
) = self._get_opts_and_config(empty_opts)
|
||||
|
||||
items = task.imported_items()
|
||||
|
|
@ -264,6 +289,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
threads,
|
||||
items,
|
||||
force,
|
||||
)
|
||||
|
||||
# Utilities converted from functions to methods on logging overhaul
|
||||
|
|
@ -282,7 +308,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
quiet = self.config["quiet"].get(bool)
|
||||
|
||||
if not quiet and not pretend:
|
||||
self._log.info("Encoding {0}", util.displayable_path(source))
|
||||
self._log.info("Encoding {}", util.displayable_path(source))
|
||||
|
||||
command = os.fsdecode(command)
|
||||
source = os.fsdecode(source)
|
||||
|
|
@ -301,7 +327,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
encode_cmd.append(os.fsdecode(args[i]))
|
||||
|
||||
if pretend:
|
||||
self._log.info("{0}", " ".join(ui.decargs(args)))
|
||||
self._log.info("{}", " ".join(args))
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -309,28 +335,25 @@ class ConvertPlugin(BeetsPlugin):
|
|||
except subprocess.CalledProcessError as exc:
|
||||
# Something went wrong (probably Ctrl+C), remove temporary files
|
||||
self._log.info(
|
||||
"Encoding {0} failed. Cleaning up...",
|
||||
"Encoding {} failed. Cleaning up...",
|
||||
util.displayable_path(source),
|
||||
)
|
||||
self._log.debug(
|
||||
"Command {0} exited with status {1}: {2}",
|
||||
"Command {0} exited with status {1.returncode}: {1.output}",
|
||||
args,
|
||||
exc.returncode,
|
||||
exc.output,
|
||||
exc,
|
||||
)
|
||||
util.remove(dest)
|
||||
util.prune_dirs(os.path.dirname(dest))
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ui.UserError(
|
||||
"convert: couldn't invoke '{}': {}".format(
|
||||
" ".join(ui.decargs(args)), exc
|
||||
)
|
||||
f"convert: couldn't invoke {' '.join(args)!r}: {exc}"
|
||||
)
|
||||
|
||||
if not quiet and not pretend:
|
||||
self._log.info(
|
||||
"Finished encoding {0}", util.displayable_path(source)
|
||||
"Finished encoding {}", util.displayable_path(source)
|
||||
)
|
||||
|
||||
def convert_item(
|
||||
|
|
@ -342,6 +365,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
pretend=False,
|
||||
link=False,
|
||||
hardlink=False,
|
||||
force=False,
|
||||
):
|
||||
"""A pipeline thread that converts `Item` objects from a
|
||||
library.
|
||||
|
|
@ -358,7 +382,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
try:
|
||||
mediafile.MediaFile(util.syspath(item.path))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
self._log.error("Could not open file to convert: {0}", exc)
|
||||
self._log.error("Could not open file to convert: {}", exc)
|
||||
continue
|
||||
|
||||
# When keeping the new file in the library, we first move the
|
||||
|
|
@ -367,11 +391,11 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if keep_new:
|
||||
original = dest
|
||||
converted = item.path
|
||||
if should_transcode(item, fmt):
|
||||
if should_transcode(item, fmt, force):
|
||||
converted = replace_ext(converted, ext)
|
||||
else:
|
||||
original = item.path
|
||||
if should_transcode(item, fmt):
|
||||
if should_transcode(item, fmt, force):
|
||||
dest = replace_ext(dest, ext)
|
||||
converted = dest
|
||||
|
||||
|
|
@ -384,25 +408,24 @@ class ConvertPlugin(BeetsPlugin):
|
|||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
self._log.info(
|
||||
"Skipping {0} (target file exists)",
|
||||
util.displayable_path(item.path),
|
||||
"Skipping {.filepath} (target file exists)", item
|
||||
)
|
||||
continue
|
||||
|
||||
if keep_new:
|
||||
if pretend:
|
||||
self._log.info(
|
||||
"mv {0} {1}",
|
||||
util.displayable_path(item.path),
|
||||
"mv {.filepath} {}",
|
||||
item,
|
||||
util.displayable_path(original),
|
||||
)
|
||||
else:
|
||||
self._log.info(
|
||||
"Moving to {0}", util.displayable_path(original)
|
||||
"Moving to {}", util.displayable_path(original)
|
||||
)
|
||||
util.move(item.path, original)
|
||||
|
||||
if should_transcode(item, fmt):
|
||||
if should_transcode(item, fmt, force):
|
||||
linked = False
|
||||
try:
|
||||
self.encode(command, original, converted, pretend)
|
||||
|
|
@ -414,10 +437,10 @@ class ConvertPlugin(BeetsPlugin):
|
|||
msg = "ln" if hardlink else ("ln -s" if link else "cp")
|
||||
|
||||
self._log.info(
|
||||
"{2} {0} {1}",
|
||||
"{} {} {}",
|
||||
msg,
|
||||
util.displayable_path(original),
|
||||
util.displayable_path(converted),
|
||||
msg,
|
||||
)
|
||||
else:
|
||||
# No transcoding necessary.
|
||||
|
|
@ -427,9 +450,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
else ("Linking" if link else "Copying")
|
||||
)
|
||||
|
||||
self._log.info(
|
||||
"{1} {0}", util.displayable_path(item.path), msg
|
||||
)
|
||||
self._log.info("{} {.filepath}", msg, item)
|
||||
|
||||
if hardlink:
|
||||
util.hardlink(original, converted)
|
||||
|
|
@ -445,8 +466,9 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if id3v23 == "inherit":
|
||||
id3v23 = None
|
||||
|
||||
# Write tags from the database to the converted file.
|
||||
item.try_write(path=converted, id3v23=id3v23)
|
||||
# Write tags from the database to the file if requested
|
||||
if self.config["write_metadata"].get(bool):
|
||||
item.try_write(path=converted, id3v23=id3v23)
|
||||
|
||||
if keep_new:
|
||||
# If we're keeping the transcoded file, read it again (after
|
||||
|
|
@ -460,8 +482,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if album and album.artpath:
|
||||
maxwidth = self._get_art_resize(album.artpath)
|
||||
self._log.debug(
|
||||
"embedding album art from {}",
|
||||
util.displayable_path(album.artpath),
|
||||
"embedding album art from {.art_filepath}", album
|
||||
)
|
||||
art.embed_item(
|
||||
self._log,
|
||||
|
|
@ -519,8 +540,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
self._log.info(
|
||||
"Skipping {0} (target file exists)",
|
||||
util.displayable_path(album.artpath),
|
||||
"Skipping {.art_filepath} (target file exists)", album
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -530,8 +550,8 @@ class ConvertPlugin(BeetsPlugin):
|
|||
# Either copy or resize (while copying) the image.
|
||||
if maxwidth is not None:
|
||||
self._log.info(
|
||||
"Resizing cover art from {0} to {1}",
|
||||
util.displayable_path(album.artpath),
|
||||
"Resizing cover art from {.art_filepath} to {}",
|
||||
album,
|
||||
util.displayable_path(dest),
|
||||
)
|
||||
if not pretend:
|
||||
|
|
@ -541,10 +561,10 @@ class ConvertPlugin(BeetsPlugin):
|
|||
msg = "ln" if hardlink else ("ln -s" if link else "cp")
|
||||
|
||||
self._log.info(
|
||||
"{2} {0} {1}",
|
||||
util.displayable_path(album.artpath),
|
||||
util.displayable_path(dest),
|
||||
"{} {.art_filepath} {}",
|
||||
msg,
|
||||
album,
|
||||
util.displayable_path(dest),
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
|
|
@ -554,10 +574,10 @@ class ConvertPlugin(BeetsPlugin):
|
|||
)
|
||||
|
||||
self._log.info(
|
||||
"{2} cover art from {0} to {1}",
|
||||
util.displayable_path(album.artpath),
|
||||
util.displayable_path(dest),
|
||||
"{} cover art from {.art_filepath} to {}",
|
||||
msg,
|
||||
album,
|
||||
util.displayable_path(dest),
|
||||
)
|
||||
if hardlink:
|
||||
util.hardlink(album.artpath, dest)
|
||||
|
|
@ -576,16 +596,17 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
link,
|
||||
playlist,
|
||||
force,
|
||||
) = self._get_opts_and_config(opts)
|
||||
|
||||
if opts.album:
|
||||
albums = lib.albums(ui.decargs(args))
|
||||
albums = lib.albums(args)
|
||||
items = [i for a in albums for i in a.items()]
|
||||
if not pretend:
|
||||
for a in albums:
|
||||
ui.print_(format(a, ""))
|
||||
else:
|
||||
items = list(lib.items(ui.decargs(args)))
|
||||
items = list(lib.items(args))
|
||||
if not pretend:
|
||||
for i in items:
|
||||
ui.print_(format(i, ""))
|
||||
|
|
@ -612,25 +633,20 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
threads,
|
||||
items,
|
||||
force,
|
||||
)
|
||||
|
||||
if playlist:
|
||||
# Playlist paths are understood as relative to the dest directory.
|
||||
pl_normpath = util.normpath(playlist)
|
||||
pl_dir = os.path.dirname(pl_normpath)
|
||||
self._log.info("Creating playlist file {0}", pl_normpath)
|
||||
self._log.info("Creating playlist file {}", pl_normpath)
|
||||
# Generates a list of paths to media files, ensures the paths are
|
||||
# relative to the playlist's location and translates the unicode
|
||||
# strings we get from item.destination to bytes.
|
||||
items_paths = [
|
||||
os.path.relpath(
|
||||
util.bytestring_path(
|
||||
item.destination(
|
||||
basedir=dest,
|
||||
path_formats=path_formats,
|
||||
fragment=False,
|
||||
)
|
||||
),
|
||||
item.destination(basedir=dest, path_formats=path_formats),
|
||||
pl_dir,
|
||||
)
|
||||
for item in items
|
||||
|
|
@ -652,7 +668,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
tmpdir = self.config["tmpdir"].get()
|
||||
if tmpdir:
|
||||
tmpdir = os.fsdecode(util.bytestring_path(tmpdir))
|
||||
fd, dest = tempfile.mkstemp(os.fsdecode(b"." + ext), dir=tmpdir)
|
||||
fd, dest = tempfile.mkstemp(f".{os.fsdecode(ext)}", dir=tmpdir)
|
||||
os.close(fd)
|
||||
dest = util.bytestring_path(dest)
|
||||
_temp_files.append(dest) # Delete the transcode later.
|
||||
|
|
@ -674,7 +690,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if self.config["delete_originals"]:
|
||||
self._log.log(
|
||||
logging.DEBUG if self.config["quiet"] else logging.INFO,
|
||||
"Removing original file {0}",
|
||||
"Removing original file {}",
|
||||
source_path,
|
||||
)
|
||||
util.remove(source_path, False)
|
||||
|
|
@ -740,7 +756,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
else:
|
||||
hardlink = self.config["hardlink"].get(bool)
|
||||
link = self.config["link"].get(bool)
|
||||
|
||||
force = getattr(opts, "force", False)
|
||||
return (
|
||||
dest,
|
||||
threads,
|
||||
|
|
@ -750,6 +766,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
link,
|
||||
playlist,
|
||||
force,
|
||||
)
|
||||
|
||||
def _parallel_convert(
|
||||
|
|
@ -763,13 +780,21 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink,
|
||||
threads,
|
||||
items,
|
||||
force,
|
||||
):
|
||||
"""Run the convert_item function for every items on as many thread as
|
||||
defined in threads
|
||||
"""
|
||||
convert = [
|
||||
self.convert_item(
|
||||
dest, keep_new, path_formats, fmt, pretend, link, hardlink
|
||||
dest,
|
||||
keep_new,
|
||||
path_formats,
|
||||
fmt,
|
||||
pretend,
|
||||
link,
|
||||
hardlink,
|
||||
force,
|
||||
)
|
||||
for _ in range(threads)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,38 +14,44 @@
|
|||
|
||||
"""Adds Deezer release and track search support to the autotagger"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import requests
|
||||
import unidecode
|
||||
|
||||
from beets import ui
|
||||
from beets.autotag import AlbumInfo, TrackInfo
|
||||
from beets.dbcore import types
|
||||
from beets.library import DateType
|
||||
from beets.plugins import BeetsPlugin, MetadataSourcePlugin
|
||||
from beets.util.id_extractors import deezer_id_regex
|
||||
from beets.metadata_plugins import (
|
||||
IDResponse,
|
||||
SearchApiMetadataSourcePlugin,
|
||||
SearchFilter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from beets.library import Item, Library
|
||||
|
||||
from ._typing import JSONDict
|
||||
|
||||
|
||||
class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
||||
data_source = "Deezer"
|
||||
|
||||
class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]):
|
||||
item_types = {
|
||||
"deezer_track_rank": types.INTEGER,
|
||||
"deezer_track_id": types.INTEGER,
|
||||
"deezer_updated": DateType(),
|
||||
"deezer_updated": types.DATE,
|
||||
}
|
||||
|
||||
# Base URLs for the Deezer API
|
||||
# Documentation: https://developers.deezer.com/api/
|
||||
search_url = "https://api.deezer.com/search/"
|
||||
album_url = "https://api.deezer.com/album/"
|
||||
track_url = "https://api.deezer.com/track/"
|
||||
|
||||
id_regex = deezer_id_regex
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def commands(self):
|
||||
|
|
@ -54,42 +60,23 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
"deezerupdate", help=f"Update {self.data_source} rank"
|
||||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
items = lib.items(ui.decargs(args))
|
||||
self.deezerupdate(items, ui.should_write())
|
||||
def func(lib: Library, opts, args):
|
||||
items = lib.items(args)
|
||||
self.deezerupdate(list(items), ui.should_write())
|
||||
|
||||
deezer_update_cmd.func = func
|
||||
|
||||
return [deezer_update_cmd]
|
||||
|
||||
def fetch_data(self, url):
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._log.error("Error fetching data from {}\n Error: {}", url, e)
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""Fetch an album by its Deezer ID or URL."""
|
||||
if not (deezer_id := self._extract_id(album_id)):
|
||||
return None
|
||||
if "error" in data:
|
||||
self._log.debug("Deezer API error: {}", data["error"]["message"])
|
||||
return None
|
||||
return data
|
||||
|
||||
def album_for_id(self, album_id):
|
||||
"""Fetch an album by its Deezer ID or URL and return an
|
||||
AlbumInfo object or None if the album is not found.
|
||||
album_url = f"{self.album_url}{deezer_id}"
|
||||
if not (album_data := self.fetch_data(album_url)):
|
||||
return None
|
||||
|
||||
:param album_id: Deezer ID or URL for the album.
|
||||
:type album_id: str
|
||||
:return: AlbumInfo object for album.
|
||||
:rtype: beets.autotag.hooks.AlbumInfo or None
|
||||
"""
|
||||
deezer_id = self._get_id("album", album_id, self.id_regex)
|
||||
if deezer_id is None:
|
||||
return None
|
||||
album_data = self.fetch_data(self.album_url + deezer_id)
|
||||
if album_data is None:
|
||||
return None
|
||||
contributors = album_data.get("contributors")
|
||||
if contributors is not None:
|
||||
artist, artist_id = self.get_artist(contributors)
|
||||
|
|
@ -114,7 +101,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
f"Invalid `release_date` returned by {self.data_source} API: "
|
||||
f"{release_date!r}"
|
||||
)
|
||||
tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks")
|
||||
tracks_obj = self.fetch_data(f"{self.album_url}{deezer_id}/tracks")
|
||||
if tracks_obj is None:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -132,7 +119,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
tracks_data.extend(tracks_obj["data"])
|
||||
|
||||
tracks = []
|
||||
medium_totals = collections.defaultdict(int)
|
||||
medium_totals: dict[int | None, int] = collections.defaultdict(int)
|
||||
for i, track_data in enumerate(tracks_data, start=1):
|
||||
track = self._get_track(track_data)
|
||||
track.index = i
|
||||
|
|
@ -150,25 +137,68 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
artist_id=artist_id,
|
||||
tracks=tracks,
|
||||
albumtype=album_data["record_type"],
|
||||
va=len(album_data["contributors"]) == 1
|
||||
and artist.lower() == "various artists",
|
||||
va=(
|
||||
len(album_data["contributors"]) == 1
|
||||
and (artist or "").lower() == "various artists"
|
||||
),
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
label=album_data["label"],
|
||||
mediums=max(medium_totals.keys()),
|
||||
mediums=max(filter(None, medium_totals.keys())),
|
||||
data_source=self.data_source,
|
||||
data_url=album_data["link"],
|
||||
cover_art_url=album_data.get("cover_xl"),
|
||||
)
|
||||
|
||||
def _get_track(self, track_data):
|
||||
def track_for_id(self, track_id: str) -> None | TrackInfo:
|
||||
"""Fetch a track by its Deezer ID or URL and return a
|
||||
TrackInfo object or None if the track is not found.
|
||||
|
||||
:param track_id: (Optional) Deezer ID or URL for the track. Either
|
||||
``track_id`` or ``track_data`` must be provided.
|
||||
|
||||
"""
|
||||
if not (deezer_id := self._extract_id(track_id)):
|
||||
self._log.debug("Invalid Deezer track_id: {}", track_id)
|
||||
return None
|
||||
|
||||
if not (track_data := self.fetch_data(f"{self.track_url}{deezer_id}")):
|
||||
self._log.debug("Track not found: {}", track_id)
|
||||
return None
|
||||
|
||||
track = self._get_track(track_data)
|
||||
|
||||
# Get album's tracks to set `track.index` (position on the entire
|
||||
# release) and `track.medium_total` (total number of tracks on
|
||||
# the track's disc).
|
||||
if not (
|
||||
album_tracks_obj := self.fetch_data(
|
||||
f"{self.album_url}{track_data['album']['id']}/tracks"
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
album_tracks_data = album_tracks_obj["data"]
|
||||
except KeyError:
|
||||
self._log.debug(
|
||||
"Error fetching album tracks for {}", track_data["album"]["id"]
|
||||
)
|
||||
return None
|
||||
medium_total = 0
|
||||
for i, track_data in enumerate(album_tracks_data, start=1):
|
||||
if track_data["disk_number"] == track.medium:
|
||||
medium_total += 1
|
||||
if track_data["id"] == track.track_id:
|
||||
track.index = i
|
||||
track.medium_total = medium_total
|
||||
return track
|
||||
|
||||
def _get_track(self, track_data: JSONDict) -> TrackInfo:
|
||||
"""Convert a Deezer track object dict to a TrackInfo object.
|
||||
|
||||
:param track_data: Deezer Track object dict
|
||||
:type track_data: dict
|
||||
:return: TrackInfo object for track
|
||||
:rtype: beets.autotag.hooks.TrackInfo
|
||||
"""
|
||||
artist, artist_id = self.get_artist(
|
||||
track_data.get("contributors", [track_data["artist"]])
|
||||
|
|
@ -190,118 +220,60 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
deezer_updated=time.time(),
|
||||
)
|
||||
|
||||
def track_for_id(self, track_id=None, track_data=None):
|
||||
"""Fetch a track by its Deezer ID or URL and return a
|
||||
TrackInfo object or None if the track is not found.
|
||||
|
||||
:param track_id: (Optional) Deezer ID or URL for the track. Either
|
||||
``track_id`` or ``track_data`` must be provided.
|
||||
:type track_id: str
|
||||
:param track_data: (Optional) Simplified track object dict. May be
|
||||
provided instead of ``track_id`` to avoid unnecessary API calls.
|
||||
:type track_data: dict
|
||||
:return: TrackInfo object for track
|
||||
:rtype: beets.autotag.hooks.TrackInfo or None
|
||||
"""
|
||||
if track_data is None:
|
||||
deezer_id = self._get_id("track", track_id, self.id_regex)
|
||||
if deezer_id is None:
|
||||
return None
|
||||
track_data = self.fetch_data(self.track_url + deezer_id)
|
||||
if track_data is None:
|
||||
return None
|
||||
track = self._get_track(track_data)
|
||||
|
||||
# Get album's tracks to set `track.index` (position on the entire
|
||||
# release) and `track.medium_total` (total number of tracks on
|
||||
# the track's disc).
|
||||
album_tracks_obj = self.fetch_data(
|
||||
self.album_url + str(track_data["album"]["id"]) + "/tracks"
|
||||
)
|
||||
if album_tracks_obj is None:
|
||||
return None
|
||||
try:
|
||||
album_tracks_data = album_tracks_obj["data"]
|
||||
except KeyError:
|
||||
self._log.debug(
|
||||
"Error fetching album tracks for {}", track_data["album"]["id"]
|
||||
)
|
||||
return None
|
||||
medium_total = 0
|
||||
for i, track_data in enumerate(album_tracks_data, start=1):
|
||||
if track_data["disk_number"] == track.medium:
|
||||
medium_total += 1
|
||||
if track_data["id"] == track.track_id:
|
||||
track.index = i
|
||||
track.medium_total = medium_total
|
||||
return track
|
||||
|
||||
@staticmethod
|
||||
def _construct_search_query(filters=None, keywords=""):
|
||||
"""Construct a query string with the specified filters and keywords to
|
||||
be provided to the Deezer Search API
|
||||
(https://developers.deezer.com/api/search).
|
||||
|
||||
:param filters: (Optional) Field filters to apply.
|
||||
:type filters: dict
|
||||
:param keywords: (Optional) Query keywords to use.
|
||||
:type keywords: str
|
||||
:return: Query string to be provided to the Search API.
|
||||
:rtype: str
|
||||
"""
|
||||
query_components = [
|
||||
keywords,
|
||||
" ".join(f'{k}:"{v}"' for k, v in filters.items()),
|
||||
]
|
||||
query = " ".join([q for q in query_components if q])
|
||||
if not isinstance(query, str):
|
||||
query = query.decode("utf8")
|
||||
return unidecode.unidecode(query)
|
||||
|
||||
def _search_api(self, query_type, filters=None, keywords=""):
|
||||
"""Query the Deezer Search API for the specified ``keywords``, applying
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal[
|
||||
"album",
|
||||
"track",
|
||||
"artist",
|
||||
"history",
|
||||
"playlist",
|
||||
"podcast",
|
||||
"radio",
|
||||
"user",
|
||||
],
|
||||
filters: SearchFilter,
|
||||
query_string: str = "",
|
||||
) -> Sequence[IDResponse]:
|
||||
"""Query the Deezer Search API for the specified ``query_string``, applying
|
||||
the provided ``filters``.
|
||||
|
||||
:param query_type: The Deezer Search API method to use. Valid types
|
||||
are: 'album', 'artist', 'history', 'playlist', 'podcast',
|
||||
'radio', 'track', 'user', and 'track'.
|
||||
:type query_type: str
|
||||
:param filters: (Optional) Field filters to apply.
|
||||
:type filters: dict
|
||||
:param keywords: (Optional) Query keywords to use.
|
||||
:type keywords: str
|
||||
:param filters: Field filters to apply.
|
||||
:param query_string: Additional query to include in the search.
|
||||
:return: JSON data for the class:`Response <Response>` object or None
|
||||
if no search results are returned.
|
||||
:rtype: dict or None
|
||||
"""
|
||||
query = self._construct_search_query(keywords=keywords, filters=filters)
|
||||
if not query:
|
||||
return None
|
||||
self._log.debug(f"Searching {self.data_source} for '{query}'")
|
||||
query = self._construct_search_query(
|
||||
query_string=query_string, filters=filters
|
||||
)
|
||||
self._log.debug("Searching {.data_source} for '{}'", self, query)
|
||||
try:
|
||||
response = requests.get(
|
||||
self.search_url + query_type,
|
||||
params={"q": query},
|
||||
f"{self.search_url}{query_type}",
|
||||
params={
|
||||
"q": query,
|
||||
"limit": self.config["search_limit"].get(),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._log.error(
|
||||
"Error fetching data from {} API\n Error: {}",
|
||||
self.data_source,
|
||||
"Error fetching data from {.data_source} API\n Error: {}",
|
||||
self,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
response_data = response.json().get("data", [])
|
||||
return ()
|
||||
response_data: Sequence[IDResponse] = response.json().get("data", [])
|
||||
self._log.debug(
|
||||
"Found {} result(s) from {} for '{}'",
|
||||
"Found {} result(s) from {.data_source} for '{}'",
|
||||
len(response_data),
|
||||
self.data_source,
|
||||
self,
|
||||
query,
|
||||
)
|
||||
return response_data
|
||||
|
||||
def deezerupdate(self, items, write):
|
||||
def deezerupdate(self, items: Sequence[Item], write: bool):
|
||||
"""Obtain rank information from Deezer."""
|
||||
for index, item in enumerate(items, start=1):
|
||||
self._log.info(
|
||||
|
|
@ -327,3 +299,16 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
item.deezer_updated = time.time()
|
||||
if write:
|
||||
item.try_write()
|
||||
|
||||
def fetch_data(self, url: str):
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._log.error("Error fetching data from {}\n Error: {}", url, e)
|
||||
return None
|
||||
if "error" in data:
|
||||
self._log.debug("Deezer API error: {}", data["error"]["message"])
|
||||
return None
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -25,20 +25,27 @@ import re
|
|||
import socket
|
||||
import time
|
||||
import traceback
|
||||
from functools import cache
|
||||
from string import ascii_lowercase
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import confuse
|
||||
from discogs_client import Client, Master, Release
|
||||
from discogs_client.exceptions import DiscogsAPIError
|
||||
from requests.exceptions import ConnectionError
|
||||
from typing_extensions import TypedDict
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
import beets
|
||||
import beets.ui
|
||||
from beets import config
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, string_dist
|
||||
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
|
||||
from beets.util.id_extractors import extract_discogs_id_regex
|
||||
from beets.autotag.distance import string_dist
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.metadata_plugins import MetadataSourcePlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
|
||||
from beets.library import Item
|
||||
|
||||
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
|
||||
API_KEY = "rAzVUQYRaoFjeBjyWuWZ"
|
||||
|
|
@ -54,13 +61,67 @@ CONNECTION_ERRORS = (
|
|||
)
|
||||
|
||||
|
||||
TRACK_INDEX_RE = re.compile(
|
||||
r"""
|
||||
(.*?) # medium: everything before medium_index.
|
||||
(\d*?) # medium_index: a number at the end of
|
||||
# `position`, except if followed by a subtrack index.
|
||||
# subtrack_index: can only be matched if medium
|
||||
# or medium_index have been matched, and can be
|
||||
(
|
||||
(?<=\w)\.[\w]+ # a dot followed by a string (A.1, 2.A)
|
||||
| (?<=\d)[A-Z]+ # a string that follows a number (1A, B2a)
|
||||
)?
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
DISAMBIGUATION_RE = re.compile(r" \(\d+\)")
|
||||
|
||||
|
||||
class ReleaseFormat(TypedDict):
|
||||
name: str
|
||||
qty: int
|
||||
descriptions: list[str] | None
|
||||
|
||||
|
||||
class DiscogsPlugin(BeetsPlugin):
|
||||
class Artist(TypedDict):
|
||||
name: str
|
||||
anv: str
|
||||
join: str
|
||||
role: str
|
||||
tracks: str
|
||||
id: str
|
||||
resource_url: str
|
||||
|
||||
|
||||
class Track(TypedDict):
|
||||
position: str
|
||||
type_: str
|
||||
title: str
|
||||
duration: str
|
||||
artists: list[Artist]
|
||||
extraartists: NotRequired[list[Artist]]
|
||||
|
||||
|
||||
class TrackWithSubtracks(Track):
|
||||
sub_tracks: list[TrackWithSubtracks]
|
||||
|
||||
|
||||
class IntermediateTrackInfo(TrackInfo):
|
||||
"""Allows work with string mediums from
|
||||
get_track_info"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
medium_str: str | None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.medium_str = medium_str
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class DiscogsPlugin(MetadataSourcePlugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config.add(
|
||||
|
|
@ -68,11 +129,17 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
"apikey": API_KEY,
|
||||
"apisecret": API_SECRET,
|
||||
"tokenfile": "discogs_token.json",
|
||||
"source_weight": 0.5,
|
||||
"user_token": "",
|
||||
"separator": ", ",
|
||||
"index_tracks": False,
|
||||
"append_style_genre": False,
|
||||
"strip_disambiguation": True,
|
||||
"featured_string": "Feat.",
|
||||
"anv": {
|
||||
"artist_credit": True,
|
||||
"artist": False,
|
||||
"album_artist": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
self.config["apikey"].redact = True
|
||||
|
|
@ -80,7 +147,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
self.config["user_token"].redact = True
|
||||
self.setup()
|
||||
|
||||
def setup(self, session=None):
|
||||
def setup(self, session=None) -> None:
|
||||
"""Create the `discogs_client` field. Authenticate if necessary."""
|
||||
c_key = self.config["apikey"].as_str()
|
||||
c_secret = self.config["apisecret"].as_str()
|
||||
|
|
@ -106,22 +173,22 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
|
||||
|
||||
def reset_auth(self):
|
||||
def reset_auth(self) -> None:
|
||||
"""Delete token file & redo the auth steps."""
|
||||
os.remove(self._tokenfile())
|
||||
self.setup()
|
||||
|
||||
def _tokenfile(self):
|
||||
def _tokenfile(self) -> str:
|
||||
"""Get the path to the JSON file for storing the OAuth token."""
|
||||
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
||||
|
||||
def authenticate(self, c_key, c_secret):
|
||||
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
|
||||
# Get the link for the OAuth page.
|
||||
auth_client = Client(USER_AGENT, c_key, c_secret)
|
||||
try:
|
||||
_, _, url = auth_client.get_authorize_url()
|
||||
except CONNECTION_ERRORS as e:
|
||||
self._log.debug("connection error: {0}", e)
|
||||
self._log.debug("connection error: {}", e)
|
||||
raise beets.ui.UserError("communication with Discogs failed")
|
||||
|
||||
beets.ui.print_("To authenticate with Discogs, visit:")
|
||||
|
|
@ -134,139 +201,53 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
except DiscogsAPIError:
|
||||
raise beets.ui.UserError("Discogs authorization failed")
|
||||
except CONNECTION_ERRORS as e:
|
||||
self._log.debug("connection error: {0}", e)
|
||||
self._log.debug("connection error: {}", e)
|
||||
raise beets.ui.UserError("Discogs token request failed")
|
||||
|
||||
# Save the token for later use.
|
||||
self._log.debug("Discogs token {0}, secret {1}", token, secret)
|
||||
self._log.debug("Discogs token {}, secret {}", token, secret)
|
||||
with open(self._tokenfile(), "w") as f:
|
||||
json.dump({"token": token, "secret": secret}, f)
|
||||
|
||||
return token, secret
|
||||
|
||||
def album_distance(self, items, album_info, mapping):
|
||||
"""Returns the album distance."""
|
||||
return get_distance(
|
||||
data_source="Discogs", info=album_info, config=self.config
|
||||
)
|
||||
def candidates(
|
||||
self, items: Sequence[Item], artist: str, album: str, va_likely: bool
|
||||
) -> Iterable[AlbumInfo]:
|
||||
return self.get_albums(f"{artist} {album}" if va_likely else album)
|
||||
|
||||
def track_distance(self, item, track_info):
|
||||
"""Returns the track distance."""
|
||||
return get_distance(
|
||||
data_source="Discogs", info=track_info, config=self.config
|
||||
)
|
||||
|
||||
def candidates(self, items, artist, album, va_likely, extra_tags=None):
|
||||
"""Returns a list of AlbumInfo objects for discogs search results
|
||||
matching an album and artist (if not various).
|
||||
"""
|
||||
if not album and not artist:
|
||||
self._log.debug(
|
||||
"Skipping Discogs query. Files missing album and artist tags."
|
||||
)
|
||||
return []
|
||||
|
||||
if va_likely:
|
||||
query = album
|
||||
else:
|
||||
query = f"{artist} {album}"
|
||||
try:
|
||||
return self.get_albums(query)
|
||||
except DiscogsAPIError as e:
|
||||
self._log.debug("API Error: {0} (query: {1})", e, query)
|
||||
if e.status_code == 401:
|
||||
self.reset_auth()
|
||||
return self.candidates(items, artist, album, va_likely)
|
||||
else:
|
||||
return []
|
||||
except CONNECTION_ERRORS:
|
||||
self._log.debug("Connection error in album search", exc_info=True)
|
||||
return []
|
||||
|
||||
def get_track_from_album_by_title(
|
||||
self, album_info, title, dist_threshold=0.3
|
||||
):
|
||||
def compare_func(track_info):
|
||||
track_title = getattr(track_info, "title", None)
|
||||
dist = string_dist(track_title, title)
|
||||
return track_title and dist < dist_threshold
|
||||
|
||||
return self.get_track_from_album(album_info, compare_func)
|
||||
|
||||
def get_track_from_album(self, album_info, compare_func):
|
||||
"""Return the first track of the release where `compare_func` returns
|
||||
true.
|
||||
|
||||
:return: TrackInfo object.
|
||||
:rtype: beets.autotag.hooks.TrackInfo
|
||||
"""
|
||||
if not album_info:
|
||||
def get_track_from_album(
|
||||
self, album_info: AlbumInfo, compare: Callable[[TrackInfo], float]
|
||||
) -> TrackInfo | None:
|
||||
"""Return the best matching track of the release."""
|
||||
scores_and_tracks = [(compare(t), t) for t in album_info.tracks]
|
||||
score, track_info = min(scores_and_tracks, key=lambda x: x[0])
|
||||
if score > 0.3:
|
||||
return None
|
||||
|
||||
for track_info in album_info.tracks:
|
||||
# check for matching position
|
||||
if not compare_func(track_info):
|
||||
continue
|
||||
track_info["artist"] = album_info.artist
|
||||
track_info["artist_id"] = album_info.artist_id
|
||||
track_info["album"] = album_info.album
|
||||
return track_info
|
||||
|
||||
# attach artist info if not provided
|
||||
if not track_info["artist"]:
|
||||
track_info["artist"] = album_info.artist
|
||||
track_info["artist_id"] = album_info.artist_id
|
||||
# attach album info
|
||||
track_info["album"] = album_info.album
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
albums = self.candidates([item], artist, title, False)
|
||||
|
||||
return track_info
|
||||
def compare_func(track_info: TrackInfo) -> float:
|
||||
return string_dist(track_info.title, title)
|
||||
|
||||
return None
|
||||
tracks = (self.get_track_from_album(a, compare_func) for a in albums)
|
||||
return list(filter(None, tracks))
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
"""Returns a list of TrackInfo objects for Search API results
|
||||
matching ``title`` and ``artist``.
|
||||
:param item: Singleton item to be matched.
|
||||
:type item: beets.library.Item
|
||||
:param artist: The artist of the track to be matched.
|
||||
:type artist: str
|
||||
:param title: The title of the track to be matched.
|
||||
:type title: str
|
||||
:return: Candidate TrackInfo objects.
|
||||
:rtype: list[beets.autotag.hooks.TrackInfo]
|
||||
"""
|
||||
if not artist and not title:
|
||||
self._log.debug(
|
||||
"Skipping Discogs query. File missing artist and title tags."
|
||||
)
|
||||
return []
|
||||
|
||||
query = f"{artist} {title}"
|
||||
try:
|
||||
albums = self.get_albums(query)
|
||||
except DiscogsAPIError as e:
|
||||
self._log.debug("API Error: {0} (query: {1})", e, query)
|
||||
if e.status_code == 401:
|
||||
self.reset_auth()
|
||||
return self.item_candidates(item, artist, title)
|
||||
else:
|
||||
return []
|
||||
except CONNECTION_ERRORS:
|
||||
self._log.debug("Connection error in track search", exc_info=True)
|
||||
candidates = []
|
||||
for album_cur in albums:
|
||||
self._log.debug("searching within album {0}", album_cur.album)
|
||||
track_result = self.get_track_from_album_by_title(
|
||||
album_cur, item["title"]
|
||||
)
|
||||
if track_result:
|
||||
candidates.append(track_result)
|
||||
# first 10 results, don't overwhelm with options
|
||||
return candidates[:10]
|
||||
|
||||
def album_for_id(self, album_id):
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""Fetches an album by its Discogs ID and returns an AlbumInfo object
|
||||
or None if the album is not found.
|
||||
"""
|
||||
self._log.debug("Searching for release {0}", album_id)
|
||||
self._log.debug("Searching for release {}", album_id)
|
||||
|
||||
discogs_id = extract_discogs_id_regex(album_id)
|
||||
discogs_id = self._extract_id(album_id)
|
||||
|
||||
if not discogs_id:
|
||||
return None
|
||||
|
|
@ -278,7 +259,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
except DiscogsAPIError as e:
|
||||
if e.status_code != 404:
|
||||
self._log.debug(
|
||||
"API Error: {0} (query: {1})",
|
||||
"API Error: {} (query: {})",
|
||||
e,
|
||||
result.data["resource_url"],
|
||||
)
|
||||
|
|
@ -291,7 +272,15 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
return None
|
||||
return self.get_album_info(result)
|
||||
|
||||
def get_albums(self, query):
|
||||
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
||||
if album := self.album_for_id(track_id):
|
||||
for track in album.tracks:
|
||||
if track.track_id == track_id:
|
||||
return track
|
||||
|
||||
return None
|
||||
|
||||
def get_albums(self, query: str) -> Iterable[AlbumInfo]:
|
||||
"""Returns a list of AlbumInfo objects for a discogs search query."""
|
||||
# Strip non-word characters from query. Things like "!" and "-" can
|
||||
# cause a query to return no results, even if they match the artist or
|
||||
|
|
@ -303,8 +292,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
query = re.sub(r"(?i)\b(CD|disc|vinyl)\s*\d+", "", query)
|
||||
|
||||
try:
|
||||
releases = self.discogs_client.search(query, type="release").page(1)
|
||||
|
||||
results = self.discogs_client.search(query, type="release")
|
||||
results.per_page = self.config["search_limit"].get()
|
||||
releases = results.page(1)
|
||||
except CONNECTION_ERRORS:
|
||||
self._log.debug(
|
||||
"Communication error while searching for {0!r}",
|
||||
|
|
@ -312,24 +302,22 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
return [
|
||||
album for album in map(self.get_album_info, releases[:5]) if album
|
||||
]
|
||||
return filter(None, map(self.get_album_info, releases))
|
||||
|
||||
def get_master_year(self, master_id):
|
||||
@cache
|
||||
def get_master_year(self, master_id: str) -> int | None:
|
||||
"""Fetches a master release given its Discogs ID and returns its year
|
||||
or None if the master release is not found.
|
||||
"""
|
||||
self._log.debug("Searching for master release {0}", master_id)
|
||||
self._log.debug("Getting master release {}", master_id)
|
||||
result = Master(self.discogs_client, {"id": master_id})
|
||||
|
||||
try:
|
||||
year = result.fetch("year")
|
||||
return year
|
||||
return result.fetch("year")
|
||||
except DiscogsAPIError as e:
|
||||
if e.status_code != 404:
|
||||
self._log.debug(
|
||||
"API Error: {0} (query: {1})",
|
||||
"API Error: {} (query: {})",
|
||||
e,
|
||||
result.data["resource_url"],
|
||||
)
|
||||
|
|
@ -355,12 +343,38 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
return media, albumtype
|
||||
|
||||
def get_album_info(self, result):
|
||||
def get_artist_with_anv(
|
||||
self, artists: list[Artist], use_anv: bool = False
|
||||
) -> tuple[str, str | None]:
|
||||
"""Iterates through a discogs result, fetching data
|
||||
if the artist anv is to be used, maps that to the name.
|
||||
Calls the parent class get_artist method."""
|
||||
artist_list: list[dict[str | int, str]] = []
|
||||
for artist_data in artists:
|
||||
a: dict[str | int, str] = {
|
||||
"name": artist_data["name"],
|
||||
"id": artist_data["id"],
|
||||
"join": artist_data.get("join", ""),
|
||||
}
|
||||
if use_anv and (anv := artist_data.get("anv", "")):
|
||||
a["name"] = anv
|
||||
artist_list.append(a)
|
||||
artist, artist_id = self.get_artist(artist_list, join_key="join")
|
||||
return self.strip_disambiguation(artist), artist_id
|
||||
|
||||
def get_album_info(self, result: Release) -> AlbumInfo | None:
|
||||
"""Returns an AlbumInfo object for a discogs Release object."""
|
||||
# Explicitly reload the `Release` fields, as they might not be yet
|
||||
# present if the result is from a `discogs_client.search()`.
|
||||
if not result.data.get("artists"):
|
||||
result.refresh()
|
||||
try:
|
||||
result.refresh()
|
||||
except CONNECTION_ERRORS:
|
||||
self._log.debug(
|
||||
"Connection error in release lookup: {0}",
|
||||
result,
|
||||
)
|
||||
return None
|
||||
|
||||
# Sanity check for required fields. The list of required fields is
|
||||
# defined at Guideline 1.3.1.a, but in practice some releases might be
|
||||
|
|
@ -376,16 +390,29 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
self._log.warning("Release does not contain the required fields")
|
||||
return None
|
||||
|
||||
artist, artist_id = MetadataSourcePlugin.get_artist(
|
||||
[a.data for a in result.artists], join_key="join"
|
||||
artist_data = [a.data for a in result.artists]
|
||||
album_artist, album_artist_id = self.get_artist_with_anv(artist_data)
|
||||
album_artist_anv, _ = self.get_artist_with_anv(
|
||||
artist_data, use_anv=True
|
||||
)
|
||||
artist_credit = album_artist_anv
|
||||
|
||||
album = re.sub(r" +", " ", result.title)
|
||||
album_id = result.data["id"]
|
||||
# Use `.data` to access the tracklist directly instead of the
|
||||
# convenient `.tracklist` property, which will strip out useful artist
|
||||
# information and leave us with skeleton `Artist` objects that will
|
||||
# each make an API call just to get the same data back.
|
||||
tracks = self.get_tracks(result.data["tracklist"])
|
||||
tracks = self.get_tracks(
|
||||
result.data["tracklist"],
|
||||
(album_artist, album_artist_anv, album_artist_id),
|
||||
)
|
||||
|
||||
# Assign ANV to the proper fields for tagging
|
||||
if not self.config["anv"]["artist_credit"]:
|
||||
artist_credit = album_artist
|
||||
if self.config["anv"]["album_artist"]:
|
||||
album_artist = album_artist_anv
|
||||
|
||||
# Extract information for the optional AlbumInfo fields, if possible.
|
||||
va = result.data["artists"][0].get("name", "").lower() == "various"
|
||||
|
|
@ -401,7 +428,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
else:
|
||||
genre = base_genre
|
||||
|
||||
discogs_albumid = extract_discogs_id_regex(result.data.get("uri"))
|
||||
discogs_albumid = self._extract_id(result.data.get("uri"))
|
||||
|
||||
# Extract information for the optional AlbumInfo fields that are
|
||||
# contained on nested discogs fields.
|
||||
|
|
@ -411,15 +438,20 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
label = catalogno = labelid = None
|
||||
if result.data.get("labels"):
|
||||
label = result.data["labels"][0].get("name")
|
||||
label = self.strip_disambiguation(
|
||||
result.data["labels"][0].get("name")
|
||||
)
|
||||
catalogno = result.data["labels"][0].get("catno")
|
||||
labelid = result.data["labels"][0].get("id")
|
||||
|
||||
cover_art_url = self.select_cover_art(result)
|
||||
|
||||
# Additional cleanups (various artists name, catalog number, media).
|
||||
# Additional cleanups
|
||||
# (various artists name, catalog number, media, disambiguation).
|
||||
if va:
|
||||
artist = config["va_name"].as_str()
|
||||
va_name = config["va_name"].as_str()
|
||||
album_artist = va_name
|
||||
artist_credit = va_name
|
||||
if catalogno == "none":
|
||||
catalogno = None
|
||||
# Explicitly set the `media` for the tracks, since it is expected by
|
||||
|
|
@ -427,13 +459,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
for track in tracks:
|
||||
track.media = media
|
||||
track.medium_total = mediums.count(track.medium)
|
||||
if not track.artist: # get_track_info often fails to find artist
|
||||
track.artist = artist
|
||||
if not track.artist_id:
|
||||
track.artist_id = artist_id
|
||||
# Discogs does not have track IDs. Invent our own IDs as proposed
|
||||
# in #2336.
|
||||
track.track_id = str(album_id) + "-" + track.track_alt
|
||||
track.track_id = f"{album_id}-{track.track_alt}"
|
||||
track.data_url = data_url
|
||||
track.data_source = "Discogs"
|
||||
|
||||
|
|
@ -446,8 +474,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
return AlbumInfo(
|
||||
album=album,
|
||||
album_id=album_id,
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
artist=album_artist,
|
||||
artist_credit=artist_credit,
|
||||
artist_id=album_artist_id,
|
||||
tracks=tracks,
|
||||
albumtype=albumtype,
|
||||
va=va,
|
||||
|
|
@ -461,15 +490,15 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
genre=genre,
|
||||
media=media,
|
||||
original_year=original_year,
|
||||
data_source="Discogs",
|
||||
data_source=self.data_source,
|
||||
data_url=data_url,
|
||||
discogs_albumid=discogs_albumid,
|
||||
discogs_labelid=labelid,
|
||||
discogs_artistid=artist_id,
|
||||
discogs_artistid=album_artist_id,
|
||||
cover_art_url=cover_art_url,
|
||||
)
|
||||
|
||||
def select_cover_art(self, result):
|
||||
def select_cover_art(self, result: Release) -> str | None:
|
||||
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
|
||||
if result.data.get("images") and len(result.data.get("images")) > 0:
|
||||
# The first image in this list appears to be the one displayed first
|
||||
|
|
@ -479,7 +508,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
return None
|
||||
|
||||
def format(self, classification):
|
||||
def format(self, classification: Iterable[str]) -> str | None:
|
||||
if classification:
|
||||
return (
|
||||
self.config["separator"].as_str().join(sorted(classification))
|
||||
|
|
@ -487,22 +516,17 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
else:
|
||||
return None
|
||||
|
||||
def get_tracks(self, tracklist):
|
||||
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
||||
try:
|
||||
clean_tracklist = self.coalesce_tracks(tracklist)
|
||||
except Exception as exc:
|
||||
# FIXME: this is an extra precaution for making sure there are no
|
||||
# side effects after #2222. It should be removed after further
|
||||
# testing.
|
||||
self._log.debug("{}", traceback.format_exc())
|
||||
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
||||
clean_tracklist = tracklist
|
||||
tracks = []
|
||||
def _process_clean_tracklist(
|
||||
self,
|
||||
clean_tracklist: list[Track],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]:
|
||||
# Distinct works and intra-work divisions, as defined by index tracks.
|
||||
tracks: list[TrackInfo] = []
|
||||
index_tracks = {}
|
||||
index = 0
|
||||
# Distinct works and intra-work divisions, as defined by index tracks.
|
||||
divisions, next_divisions = [], []
|
||||
divisions: list[str] = []
|
||||
next_divisions: list[str] = []
|
||||
for track in clean_tracklist:
|
||||
# Only real tracks have `position`. Otherwise, it's an index track.
|
||||
if track["position"]:
|
||||
|
|
@ -512,7 +536,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# divisions.
|
||||
divisions += next_divisions
|
||||
del next_divisions[:]
|
||||
track_info = self.get_track_info(track, index, divisions)
|
||||
track_info = self.get_track_info(
|
||||
track, index, divisions, album_artist_data
|
||||
)
|
||||
track_info.track_alt = track["position"]
|
||||
tracks.append(track_info)
|
||||
else:
|
||||
|
|
@ -524,7 +550,29 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
except IndexError:
|
||||
pass
|
||||
index_tracks[index + 1] = track["title"]
|
||||
return tracks, index_tracks, index, divisions, next_divisions
|
||||
|
||||
def get_tracks(
|
||||
self,
|
||||
tracklist: list[Track],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> list[TrackInfo]:
|
||||
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
||||
try:
|
||||
clean_tracklist: list[Track] = self.coalesce_tracks(
|
||||
cast(list[TrackWithSubtracks], tracklist)
|
||||
)
|
||||
except Exception as exc:
|
||||
# FIXME: this is an extra precaution for making sure there are no
|
||||
# side effects after #2222. It should be removed after further
|
||||
# testing.
|
||||
self._log.debug("{}", traceback.format_exc())
|
||||
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
||||
clean_tracklist = tracklist
|
||||
processed = self._process_clean_tracklist(
|
||||
clean_tracklist, album_artist_data
|
||||
)
|
||||
tracks, index_tracks, index, divisions, next_divisions = processed
|
||||
# Fix up medium and medium_index for each track. Discogs position is
|
||||
# unreliable, but tracks are in order.
|
||||
medium = None
|
||||
|
|
@ -533,8 +581,8 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
# If a medium has two sides (ie. vinyl or cassette), each pair of
|
||||
# consecutive sides should belong to the same medium.
|
||||
if all([track.medium is not None for track in tracks]):
|
||||
m = sorted({track.medium.lower() for track in tracks})
|
||||
if all([track.medium_str is not None for track in tracks]):
|
||||
m = sorted({track.medium_str.lower() for track in tracks})
|
||||
# If all track.medium are single consecutive letters, assume it is
|
||||
# a 2-sided medium.
|
||||
if "".join(m) in ascii_lowercase:
|
||||
|
|
@ -548,17 +596,17 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# side_count is the number of mediums or medium sides (in the case
|
||||
# of two-sided mediums) that were seen before.
|
||||
medium_is_index = (
|
||||
track.medium
|
||||
track.medium_str
|
||||
and not track.medium_index
|
||||
and (
|
||||
len(track.medium) != 1
|
||||
len(track.medium_str) != 1
|
||||
or
|
||||
# Not within standard incremental medium values (A, B, C, ...).
|
||||
ord(track.medium) - 64 != side_count + 1
|
||||
ord(track.medium_str) - 64 != side_count + 1
|
||||
)
|
||||
)
|
||||
|
||||
if not medium_is_index and medium != track.medium:
|
||||
if not medium_is_index and medium != track.medium_str:
|
||||
side_count += 1
|
||||
if sides_per_medium == 2:
|
||||
if side_count % sides_per_medium:
|
||||
|
|
@ -569,7 +617,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# Medium changed. Reset index_count.
|
||||
medium_count += 1
|
||||
index_count = 0
|
||||
medium = track.medium
|
||||
medium = track.medium_str
|
||||
|
||||
index_count += 1
|
||||
medium_count = 1 if medium_count == 0 else medium_count
|
||||
|
|
@ -585,22 +633,27 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
disctitle = None
|
||||
track.disctitle = disctitle
|
||||
|
||||
return tracks
|
||||
return cast(list[TrackInfo], tracks)
|
||||
|
||||
def coalesce_tracks(self, raw_tracklist):
|
||||
def coalesce_tracks(
|
||||
self, raw_tracklist: list[TrackWithSubtracks]
|
||||
) -> list[Track]:
|
||||
"""Pre-process a tracklist, merging subtracks into a single track. The
|
||||
title for the merged track is the one from the previous index track,
|
||||
if present; otherwise it is a combination of the subtracks titles.
|
||||
"""
|
||||
|
||||
def add_merged_subtracks(tracklist, subtracks):
|
||||
def add_merged_subtracks(
|
||||
tracklist: list[TrackWithSubtracks],
|
||||
subtracks: list[TrackWithSubtracks],
|
||||
) -> None:
|
||||
"""Modify `tracklist` in place, merging a list of `subtracks` into
|
||||
a single track into `tracklist`."""
|
||||
# Calculate position based on first subtrack, without subindex.
|
||||
idx, medium_idx, sub_idx = self.get_track_index(
|
||||
subtracks[0]["position"]
|
||||
)
|
||||
position = "{}{}".format(idx or "", medium_idx or "")
|
||||
position = f"{idx or ''}{medium_idx or ''}"
|
||||
|
||||
if tracklist and not tracklist[-1]["position"]:
|
||||
# Assume the previous index track contains the track title.
|
||||
|
|
@ -622,8 +675,8 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# option is set
|
||||
if self.config["index_tracks"]:
|
||||
for subtrack in subtracks:
|
||||
subtrack["title"] = "{}: {}".format(
|
||||
index_track["title"], subtrack["title"]
|
||||
subtrack["title"] = (
|
||||
f"{index_track['title']}: {subtrack['title']}"
|
||||
)
|
||||
tracklist.extend(subtracks)
|
||||
else:
|
||||
|
|
@ -633,8 +686,8 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
tracklist.append(track)
|
||||
|
||||
# Pre-process the tracklist, trying to identify subtracks.
|
||||
subtracks = []
|
||||
tracklist = []
|
||||
subtracks: list[TrackWithSubtracks] = []
|
||||
tracklist: list[TrackWithSubtracks] = []
|
||||
prev_subindex = ""
|
||||
for track in raw_tracklist:
|
||||
# Regular subtrack (track with subindex).
|
||||
|
|
@ -669,10 +722,32 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
if subtracks:
|
||||
add_merged_subtracks(tracklist, subtracks)
|
||||
|
||||
return tracklist
|
||||
return cast(list[Track], tracklist)
|
||||
|
||||
def get_track_info(self, track, index, divisions):
|
||||
def strip_disambiguation(self, text: str) -> str:
|
||||
"""Removes discogs specific disambiguations from a string.
|
||||
Turns 'Label Name (5)' to 'Label Name' or 'Artist (1) & Another Artist (2)'
|
||||
to 'Artist & Another Artist'. Does nothing if strip_disambiguation is False."""
|
||||
if not self.config["strip_disambiguation"]:
|
||||
return text
|
||||
return DISAMBIGUATION_RE.sub("", text)
|
||||
|
||||
def get_track_info(
|
||||
self,
|
||||
track: Track,
|
||||
index: int,
|
||||
divisions: list[str],
|
||||
album_artist_data: tuple[str, str, str | None],
|
||||
) -> IntermediateTrackInfo:
|
||||
"""Returns a TrackInfo object for a discogs track."""
|
||||
|
||||
artist, artist_anv, artist_id = album_artist_data
|
||||
artist_credit = artist_anv
|
||||
if not self.config["anv"]["artist_credit"]:
|
||||
artist_credit = artist
|
||||
if self.config["anv"]["artist"]:
|
||||
artist = artist_anv
|
||||
|
||||
title = track["title"]
|
||||
if self.config["index_tracks"]:
|
||||
prefix = ", ".join(divisions)
|
||||
|
|
@ -680,51 +755,65 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
title = f"{prefix}: {title}"
|
||||
track_id = None
|
||||
medium, medium_index, _ = self.get_track_index(track["position"])
|
||||
artist, artist_id = MetadataSourcePlugin.get_artist(
|
||||
track.get("artists", []), join_key="join"
|
||||
)
|
||||
|
||||
# If artists are found on the track, we will use those instead
|
||||
if artists := track.get("artists", []):
|
||||
artist, artist_id = self.get_artist_with_anv(
|
||||
artists, self.config["anv"]["artist"]
|
||||
)
|
||||
artist_credit, _ = self.get_artist_with_anv(
|
||||
artists, self.config["anv"]["artist_credit"]
|
||||
)
|
||||
length = self.get_track_length(track["duration"])
|
||||
return TrackInfo(
|
||||
|
||||
# Add featured artists
|
||||
if extraartists := track.get("extraartists", []):
|
||||
featured_list = [
|
||||
artist
|
||||
for artist in extraartists
|
||||
if "Featuring" in artist["role"]
|
||||
]
|
||||
featured, _ = self.get_artist_with_anv(
|
||||
featured_list, self.config["anv"]["artist"]
|
||||
)
|
||||
featured_credit, _ = self.get_artist_with_anv(
|
||||
featured_list, self.config["anv"]["artist_credit"]
|
||||
)
|
||||
if featured:
|
||||
artist += f" {self.config['featured_string']} {featured}"
|
||||
artist_credit += (
|
||||
f" {self.config['featured_string']} {featured_credit}"
|
||||
)
|
||||
return IntermediateTrackInfo(
|
||||
title=title,
|
||||
track_id=track_id,
|
||||
artist_credit=artist_credit,
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
length=length,
|
||||
index=index,
|
||||
medium=medium,
|
||||
medium_str=medium,
|
||||
medium_index=medium_index,
|
||||
)
|
||||
|
||||
def get_track_index(self, position):
|
||||
@staticmethod
|
||||
def get_track_index(
|
||||
position: str,
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Returns the medium, medium index and subtrack index for a discogs
|
||||
track position."""
|
||||
# Match the standard Discogs positions (12.2.9), which can have several
|
||||
# forms (1, 1-1, A1, A1.1, A1a, ...).
|
||||
match = re.match(
|
||||
r"^(.*?)" # medium: everything before medium_index.
|
||||
r"(\d*?)" # medium_index: a number at the end of
|
||||
# `position`, except if followed by a subtrack
|
||||
# index.
|
||||
# subtrack_index: can only be matched if medium
|
||||
# or medium_index have been matched, and can be
|
||||
r"((?<=\w)\.[\w]+" # - a dot followed by a string (A.1, 2.A)
|
||||
r"|(?<=\d)[A-Z]+" # - a string that follows a number (1A, B2a)
|
||||
r")?"
|
||||
r"$",
|
||||
position.upper(),
|
||||
)
|
||||
|
||||
if match:
|
||||
medium = index = subindex = None
|
||||
if match := TRACK_INDEX_RE.fullmatch(position.upper()):
|
||||
medium, index, subindex = match.groups()
|
||||
|
||||
if subindex and subindex.startswith("."):
|
||||
subindex = subindex[1:]
|
||||
else:
|
||||
self._log.debug("Invalid position: {0}", position)
|
||||
medium = index = subindex = None
|
||||
|
||||
return medium or None, index or None, subindex or None
|
||||
|
||||
def get_track_length(self, duration):
|
||||
def get_track_length(self, duration: str) -> int | None:
|
||||
"""Returns the track length in seconds for a discogs duration."""
|
||||
try:
|
||||
length = time.strptime(duration, "%M:%S")
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import shlex
|
|||
|
||||
from beets.library import Album, Item
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, UserError, decargs, print_
|
||||
from beets.ui import Subcommand, UserError, print_
|
||||
from beets.util import (
|
||||
MoveOperation,
|
||||
bytestring_path,
|
||||
|
|
@ -53,6 +53,7 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
"tiebreak": {},
|
||||
"strict": False,
|
||||
"tag": "",
|
||||
"remove": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -131,6 +132,13 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
action="store",
|
||||
help="tag matched items with 'k=v' attribute",
|
||||
)
|
||||
self._command.parser.add_option(
|
||||
"-r",
|
||||
"--remove",
|
||||
dest="remove",
|
||||
action="store_true",
|
||||
help="remove items from library",
|
||||
)
|
||||
self._command.parser.add_all_common_options()
|
||||
|
||||
def commands(self):
|
||||
|
|
@ -141,7 +149,8 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
copy = bytestring_path(self.config["copy"].as_str())
|
||||
count = self.config["count"].get(bool)
|
||||
delete = self.config["delete"].get(bool)
|
||||
fmt = self.config["format"].get(str)
|
||||
remove = self.config["remove"].get(bool)
|
||||
fmt_tmpl = self.config["format"].get(str)
|
||||
full = self.config["full"].get(bool)
|
||||
keys = self.config["keys"].as_str_seq()
|
||||
merge = self.config["merge"].get(bool)
|
||||
|
|
@ -154,11 +163,11 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
if album:
|
||||
if not keys:
|
||||
keys = ["mb_albumid"]
|
||||
items = lib.albums(decargs(args))
|
||||
items = lib.albums(args)
|
||||
else:
|
||||
if not keys:
|
||||
keys = ["mb_trackid", "mb_albumid"]
|
||||
items = lib.items(decargs(args))
|
||||
items = lib.items(args)
|
||||
|
||||
# If there's nothing to do, return early. The code below assumes
|
||||
# `items` to be non-empty.
|
||||
|
|
@ -166,15 +175,14 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
return
|
||||
|
||||
if path:
|
||||
fmt = "$path"
|
||||
fmt_tmpl = "$path"
|
||||
|
||||
# Default format string for count mode.
|
||||
if count and not fmt:
|
||||
if count and not fmt_tmpl:
|
||||
if album:
|
||||
fmt = "$albumartist - $album"
|
||||
fmt_tmpl = "$albumartist - $album"
|
||||
else:
|
||||
fmt = "$albumartist - $album - $title"
|
||||
fmt += ": {0}"
|
||||
fmt_tmpl = "$albumartist - $album - $title"
|
||||
|
||||
if checksum:
|
||||
for i in items:
|
||||
|
|
@ -196,15 +204,23 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
copy=copy,
|
||||
move=move,
|
||||
delete=delete,
|
||||
remove=remove,
|
||||
tag=tag,
|
||||
fmt=fmt.format(obj_count),
|
||||
fmt=f"{fmt_tmpl}: {obj_count}",
|
||||
)
|
||||
|
||||
self._command.func = _dup
|
||||
return [self._command]
|
||||
|
||||
def _process_item(
|
||||
self, item, copy=False, move=False, delete=False, tag=False, fmt=""
|
||||
self,
|
||||
item,
|
||||
copy=False,
|
||||
move=False,
|
||||
delete=False,
|
||||
tag=False,
|
||||
fmt="",
|
||||
remove=False,
|
||||
):
|
||||
"""Process Item `item`."""
|
||||
print_(format(item, fmt))
|
||||
|
|
@ -216,6 +232,8 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
item.store()
|
||||
if delete:
|
||||
item.remove(delete=True)
|
||||
elif remove:
|
||||
item.remove(delete=False)
|
||||
if tag:
|
||||
try:
|
||||
k, v = tag.split("=")
|
||||
|
|
@ -236,28 +254,24 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
checksum = getattr(item, key, False)
|
||||
if not checksum:
|
||||
self._log.debug(
|
||||
"key {0} on item {1} not cached:" "computing checksum",
|
||||
"key {} on item {.filepath} not cached:computing checksum",
|
||||
key,
|
||||
displayable_path(item.path),
|
||||
item,
|
||||
)
|
||||
try:
|
||||
checksum = command_output(args).stdout
|
||||
setattr(item, key, checksum)
|
||||
item.store()
|
||||
self._log.debug(
|
||||
"computed checksum for {0} using {1}", item.title, key
|
||||
"computed checksum for {.title} using {}", item, key
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._log.debug(
|
||||
"failed to checksum {0}: {1}",
|
||||
displayable_path(item.path),
|
||||
e,
|
||||
)
|
||||
self._log.debug("failed to checksum {.filepath}: {}", item, e)
|
||||
else:
|
||||
self._log.debug(
|
||||
"key {0} on item {1} cached:" "not computing checksum",
|
||||
"key {} on item {.filepath} cached:not computing checksum",
|
||||
key,
|
||||
displayable_path(item.path),
|
||||
item,
|
||||
)
|
||||
return key, checksum
|
||||
|
||||
|
|
@ -275,15 +289,15 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
values = [v for v in values if v not in (None, "")]
|
||||
if strict and len(values) < len(keys):
|
||||
self._log.debug(
|
||||
"some keys {0} on item {1} are null or empty:" " skipping",
|
||||
"some keys {} on item {.filepath} are null or empty: skipping",
|
||||
keys,
|
||||
displayable_path(obj.path),
|
||||
obj,
|
||||
)
|
||||
elif not strict and not len(values):
|
||||
self._log.debug(
|
||||
"all keys {0} on item {1} are null or empty:" " skipping",
|
||||
"all keys {} on item {.filepath} are null or empty: skipping",
|
||||
keys,
|
||||
displayable_path(obj.path),
|
||||
obj,
|
||||
)
|
||||
else:
|
||||
key = tuple(values)
|
||||
|
|
@ -341,11 +355,11 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
value = getattr(o, f, None)
|
||||
if value:
|
||||
self._log.debug(
|
||||
"key {0} on item {1} is null "
|
||||
"or empty: setting from item {2}",
|
||||
"key {} on item {} is null "
|
||||
"or empty: setting from item {.filepath}",
|
||||
f,
|
||||
displayable_path(objs[0].path),
|
||||
displayable_path(o.path),
|
||||
o,
|
||||
)
|
||||
setattr(objs[0], f, value)
|
||||
objs[0].store()
|
||||
|
|
@ -365,11 +379,11 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
missing.album_id = objs[0].id
|
||||
missing.add(i._db)
|
||||
self._log.debug(
|
||||
"item {0} missing from album {1}:"
|
||||
" merging from {2} into {3}",
|
||||
"item {} missing from album {}:"
|
||||
" merging from {.filepath} into {}",
|
||||
missing,
|
||||
objs[0],
|
||||
displayable_path(o.path),
|
||||
o,
|
||||
displayable_path(missing.destination()),
|
||||
)
|
||||
missing.move(operation=MoveOperation.COPY)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ import yaml
|
|||
|
||||
from beets import plugins, ui, util
|
||||
from beets.dbcore import types
|
||||
from beets.importer import action
|
||||
from beets.ui.commands import PromptChoice, _do_query
|
||||
from beets.importer import Action
|
||||
from beets.ui.commands.utils import do_query
|
||||
from beets.util import PromptChoice
|
||||
|
||||
# These "safe" types can avoid the format/parse cycle that most fields go
|
||||
# through: they are safe to edit with native YAML types.
|
||||
|
|
@ -46,9 +47,7 @@ def edit(filename, log):
|
|||
try:
|
||||
subprocess.call(cmd)
|
||||
except OSError as exc:
|
||||
raise ui.UserError(
|
||||
"could not run editor command {!r}: {}".format(cmd[0], exc)
|
||||
)
|
||||
raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
|
||||
|
||||
|
||||
def dump(arg):
|
||||
|
|
@ -71,9 +70,7 @@ def load(s):
|
|||
for d in yaml.safe_load_all(s):
|
||||
if not isinstance(d, dict):
|
||||
raise ParseError(
|
||||
"each entry must be a dictionary; found {}".format(
|
||||
type(d).__name__
|
||||
)
|
||||
f"each entry must be a dictionary; found {type(d).__name__}"
|
||||
)
|
||||
|
||||
# Convert all keys to strings. They started out as strings,
|
||||
|
|
@ -180,8 +177,7 @@ class EditPlugin(plugins.BeetsPlugin):
|
|||
def _edit_command(self, lib, opts, args):
|
||||
"""The CLI command function for the `beet edit` command."""
|
||||
# Get the objects to edit.
|
||||
query = ui.decargs(args)
|
||||
items, albums = _do_query(lib, query, opts.album, False)
|
||||
items, albums = do_query(lib, args, opts.album, False)
|
||||
objs = albums if opts.album else items
|
||||
if not objs:
|
||||
ui.print_("Nothing to edit.")
|
||||
|
|
@ -279,23 +275,18 @@ class EditPlugin(plugins.BeetsPlugin):
|
|||
ui.print_("No changes to apply.")
|
||||
return False
|
||||
|
||||
# Confirm the changes.
|
||||
# For cancel/keep-editing, restore objects to their original
|
||||
# in-memory state so temp edits don't leak into the session
|
||||
choice = ui.input_options(
|
||||
("continue Editing", "apply", "cancel")
|
||||
)
|
||||
if choice == "a": # Apply.
|
||||
return True
|
||||
elif choice == "c": # Cancel.
|
||||
self.apply_data(objs, new_data, old_data)
|
||||
return False
|
||||
elif choice == "e": # Keep editing.
|
||||
# Reset the temporary changes to the objects. I we have a
|
||||
# copy from above, use that, else reload from the database.
|
||||
objs = [
|
||||
(old_obj or obj) for old_obj, obj in zip(objs_old, objs)
|
||||
]
|
||||
for obj in objs:
|
||||
if not obj.id < 0:
|
||||
obj.load()
|
||||
self.apply_data(objs, new_data, old_data)
|
||||
continue
|
||||
|
||||
# Remove the temporary file before returning.
|
||||
|
|
@ -380,13 +371,11 @@ class EditPlugin(plugins.BeetsPlugin):
|
|||
|
||||
# Save the new data.
|
||||
if success:
|
||||
# Return action.RETAG, which makes the importer write the tags
|
||||
# Return Action.RETAG, which makes the importer write the tags
|
||||
# to the files if needed without re-applying metadata.
|
||||
return action.RETAG
|
||||
return Action.RETAG
|
||||
else:
|
||||
# Edit cancelled / no edits made. Revert changes.
|
||||
for obj in task.items:
|
||||
obj.read()
|
||||
return None
|
||||
|
||||
def importer_edit_candidate(self, session, task):
|
||||
"""Callback for invoking the functionality during an interactive
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ from mimetypes import guess_extension
|
|||
|
||||
import requests
|
||||
|
||||
from beets import art, config, ui
|
||||
from beets import config, ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs, print_
|
||||
from beets.ui import print_
|
||||
from beets.util import bytestring_path, displayable_path, normpath, syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beetsplug._utils import art
|
||||
|
||||
|
||||
def _confirm(objs, album):
|
||||
|
|
@ -35,8 +36,9 @@ def _confirm(objs, album):
|
|||
to items).
|
||||
"""
|
||||
noun = "album" if album else "file"
|
||||
prompt = "Modify artwork for {} {}{} (Y/n)?".format(
|
||||
len(objs), noun, "s" if len(objs) > 1 else ""
|
||||
prompt = (
|
||||
"Modify artwork for"
|
||||
f" {len(objs)} {noun}{'s' if len(objs) > 1 else ''} (Y/n)?"
|
||||
)
|
||||
|
||||
# Show all the items or albums.
|
||||
|
|
@ -66,7 +68,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
if self.config["maxwidth"].get(int) and not ArtResizer.shared.local:
|
||||
self.config["maxwidth"] = 0
|
||||
self._log.warning(
|
||||
"ImageMagick or PIL not found; " "'maxwidth' option ignored"
|
||||
"ImageMagick or PIL not found; 'maxwidth' option ignored"
|
||||
)
|
||||
if (
|
||||
self.config["compare_threshold"].get(int)
|
||||
|
|
@ -110,12 +112,10 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
imagepath = normpath(opts.file)
|
||||
if not os.path.isfile(syspath(imagepath)):
|
||||
raise ui.UserError(
|
||||
"image file {} not found".format(
|
||||
displayable_path(imagepath)
|
||||
)
|
||||
f"image file {displayable_path(imagepath)} not found"
|
||||
)
|
||||
|
||||
items = lib.items(decargs(args))
|
||||
items = lib.items(args)
|
||||
|
||||
# Confirm with user.
|
||||
if not opts.yes and not _confirm(items, not opts.file):
|
||||
|
|
@ -137,7 +137,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
response = requests.get(opts.url, timeout=5)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._log.error("{}".format(e))
|
||||
self._log.error("{}", e)
|
||||
return
|
||||
extension = guess_extension(response.headers["Content-Type"])
|
||||
if extension is None:
|
||||
|
|
@ -149,9 +149,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
with open(tempimg, "wb") as f:
|
||||
f.write(response.content)
|
||||
except Exception as e:
|
||||
self._log.error("Unable to save image: {}".format(e))
|
||||
self._log.error("Unable to save image: {}", e)
|
||||
return
|
||||
items = lib.items(decargs(args))
|
||||
items = lib.items(args)
|
||||
# Confirm with user.
|
||||
if not opts.yes and not _confirm(items, not opts.url):
|
||||
os.remove(tempimg)
|
||||
|
|
@ -169,7 +169,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
)
|
||||
os.remove(tempimg)
|
||||
else:
|
||||
albums = lib.albums(decargs(args))
|
||||
albums = lib.albums(args)
|
||||
# Confirm with user.
|
||||
if not opts.yes and not _confirm(albums, not opts.file):
|
||||
return
|
||||
|
|
@ -212,7 +212,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
def extract_func(lib, opts, args):
|
||||
if opts.outpath:
|
||||
art.extract_first(
|
||||
self._log, normpath(opts.outpath), lib.items(decargs(args))
|
||||
self._log, normpath(opts.outpath), lib.items(args)
|
||||
)
|
||||
else:
|
||||
filename = bytestring_path(
|
||||
|
|
@ -223,7 +223,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
"Only specify a name rather than a path for -n"
|
||||
)
|
||||
return
|
||||
for album in lib.albums(decargs(args)):
|
||||
for album in lib.albums(args):
|
||||
artpath = normpath(os.path.join(album.path, filename))
|
||||
artpath = art.extract_first(
|
||||
self._log, artpath, album.items()
|
||||
|
|
@ -244,11 +244,11 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
)
|
||||
|
||||
def clear_func(lib, opts, args):
|
||||
items = lib.items(decargs(args))
|
||||
items = lib.items(args)
|
||||
# Confirm with user.
|
||||
if not opts.yes and not _confirm(items, False):
|
||||
return
|
||||
art.clear(self._log, lib, decargs(args))
|
||||
art.clear(self._log, lib, args)
|
||||
|
||||
clear_cmd.func = clear_func
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
"""
|
||||
if self.config["remove_art_file"] and album.artpath:
|
||||
if os.path.isfile(syspath(album.artpath)):
|
||||
self._log.debug("Removing album art file for {0}", album)
|
||||
self._log.debug("Removing album art file for {}", album)
|
||||
os.remove(syspath(album.artpath))
|
||||
album.artpath = None
|
||||
album.store()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue