mirror of
https://github.com/beetbox/beets.git
synced 2026-01-20 15:14:13 +01:00
Merge branch 'master' into master
This commit is contained in:
commit
4e2ebabb92
114 changed files with 4312 additions and 4877 deletions
|
|
@ -48,4 +48,8 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1
|
|||
# Fix formatting
|
||||
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
|
||||
# Importer restructure
|
||||
9147577b2b19f43ca827e9650261a86fb0450cef
|
||||
9147577b2b19f43ca827e9650261a86fb0450cef
|
||||
# Copy paste query, types from library to dbcore
|
||||
1a045c91668c771686f4c871c84f1680af2e944b
|
||||
# Library restructure (split library.py into multiple modules)
|
||||
0ad4e19d4f870db757373f44d12ff3be2441363a
|
||||
|
|
|
|||
12
.github/workflows/changelog_reminder.yaml
vendored
12
.github/workflows/changelog_reminder.yaml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Verify changelog updated
|
||||
|
||||
on:
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
|
@ -14,20 +14,20 @@ jobs:
|
|||
|
||||
- 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 }}'
|
||||
|
|
|
|||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.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
|
||||
|
|
@ -33,11 +33,11 @@ jobs:
|
|||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg gobject-introspection libcairo2-dev libgirepository-2.0-dev pandoc imagemagick
|
||||
sudo apt install --yes --no-install-recommends ffmpeg gobject-introspection gstreamer1.0-plugins-base python3-gst-1.0 libcairo2-dev libgirepository-2.0-dev pandoc imagemagick
|
||||
|
||||
- name: Get changed lyrics files
|
||||
id: lyrics-update
|
||||
uses: tj-actions/changed-files@v45
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
beetsplug/lyrics.py
|
||||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
- if: ${{ env.IS_MAIN_PYTHON != 'true' }}
|
||||
name: Test without coverage
|
||||
run: |
|
||||
poetry install --extras=autobpm --extras=lyrics
|
||||
poetry install --extras=autobpm --extras=lyrics --extras=embedart
|
||||
poe test
|
||||
|
||||
- if: ${{ env.IS_MAIN_PYTHON == 'true' }}
|
||||
|
|
|
|||
2
.github/workflows/integration_test.yaml
vendored
2
.github/workflows/integration_test.yaml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
|
|
|||
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
|
|
@ -22,13 +22,13 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
@ -74,7 +74,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
@ -94,7 +94,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
@ -105,7 +105,6 @@ 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 }}
|
||||
|
|
@ -118,7 +117,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
|
|||
4
.github/workflows/make_release.yaml
vendored
4
.github/workflows/make_release.yaml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
ref: ${{ env.NEW_TAG }}
|
||||
|
||||
- name: Install Python tools
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.1
|
||||
uses: BrandonLWhite/pipx-install-action@v1.0.3
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||
from jellyfish import levenshtein_distance
|
||||
from unidecode import unidecode
|
||||
|
||||
from beets import config, plugins
|
||||
from beets import config, metadata_plugins
|
||||
from beets.util import as_string, cached_classproperty, get_most_common_tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -409,7 +409,7 @@ def track_distance(
|
|||
dist.add_expr("medium", item.disc != track_info.medium)
|
||||
|
||||
# Plugins.
|
||||
dist.update(plugins.track_distance(item, track_info))
|
||||
dist.update(metadata_plugins.track_distance(item, track_info))
|
||||
|
||||
return dist
|
||||
|
||||
|
|
@ -526,6 +526,6 @@ def distance(
|
|||
dist.add("unmatched_tracks", 1.0)
|
||||
|
||||
# Plugins.
|
||||
dist.update(plugins.album_distance(items, album_info, mapping))
|
||||
dist.update(metadata_plugins.album_distance(items, album_info, mapping))
|
||||
|
||||
return dist
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
|
|||
import lap
|
||||
import numpy as np
|
||||
|
||||
from beets import config, logging, plugins
|
||||
from beets import config, logging, metadata_plugins
|
||||
from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks
|
||||
from beets.util import get_most_common_tags
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
|
|||
return None
|
||||
# If all album IDs are equal, look up the album.
|
||||
log.debug("Searching for discovered album ID: {0}", first)
|
||||
return plugins.album_for_id(first)
|
||||
return metadata_plugins.album_for_id(first)
|
||||
|
||||
|
||||
def _recommendation(
|
||||
|
|
@ -274,7 +274,7 @@ def tag_album(
|
|||
if search_ids:
|
||||
for search_id in search_ids:
|
||||
log.debug("Searching for album ID: {0}", search_id)
|
||||
if info := plugins.album_for_id(search_id):
|
||||
if info := metadata_plugins.album_for_id(search_id):
|
||||
_add_candidate(items, candidates, info)
|
||||
|
||||
# Use existing metadata or text search.
|
||||
|
|
@ -311,7 +311,7 @@ def tag_album(
|
|||
log.debug("Album might be VA: {0}", va_likely)
|
||||
|
||||
# Get the results from the data sources.
|
||||
for matched_candidate in plugins.candidates(
|
||||
for matched_candidate in metadata_plugins.candidates(
|
||||
items, search_artist, search_album, va_likely
|
||||
):
|
||||
_add_candidate(items, candidates, matched_candidate)
|
||||
|
|
@ -346,7 +346,7 @@ def tag_item(
|
|||
if trackids:
|
||||
for trackid in trackids:
|
||||
log.debug("Searching for track ID: {0}", trackid)
|
||||
if info := plugins.track_for_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.
|
||||
|
|
@ -372,7 +372,7 @@ def tag_item(
|
|||
log.debug("Item search terms: {0} - {1}", search_artist, search_title)
|
||||
|
||||
# Get and evaluate candidate metadata.
|
||||
for track_info in plugins.item_candidates(
|
||||
for track_info in metadata_plugins.item_candidates(
|
||||
item, search_artist, search_title
|
||||
):
|
||||
dist = track_distance(item, track_info, incl_artist=True)
|
||||
|
|
|
|||
|
|
@ -289,19 +289,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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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]):
|
||||
|
|
@ -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,
|
||||
|
|
@ -834,7 +893,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 +903,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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -241,7 +242,7 @@ class BaseFloat(Type[float, N]):
|
|||
"""
|
||||
|
||||
sql = "REAL"
|
||||
query: FieldQueryType = NumericQuery
|
||||
query: query.FieldQueryType = query.NumericQuery
|
||||
model_type = float
|
||||
|
||||
def __init__(self, digits: int = 1):
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ def query_tasks(session: ImportSession):
|
|||
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):
|
||||
|
|
@ -143,9 +144,7 @@ def lookup_candidates(session: ImportSession, task: ImportTask):
|
|||
|
||||
# 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.search_ids = session.config["search_ids"].as_str_seq()
|
||||
|
||||
task.lookup_candidates()
|
||||
task.lookup_candidates(session.config["search_ids"].as_str_seq())
|
||||
|
||||
|
||||
@pipeline.stage
|
||||
|
|
|
|||
|
|
@ -22,15 +22,18 @@ import time
|
|||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from tempfile import mkdtemp
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
|
||||
|
||||
import mediafile
|
||||
|
||||
from beets import autotag, config, dbcore, library, plugins, util
|
||||
from beets import autotag, config, library, plugins, util
|
||||
from beets.dbcore.query import PathQuery
|
||||
|
||||
from .state import ImportState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.autotag.match import Recommendation
|
||||
|
||||
from .session import ImportSession
|
||||
|
||||
# Global logger.
|
||||
|
|
@ -158,6 +161,7 @@ class ImportTask(BaseImportTask):
|
|||
cur_album: str | None = None
|
||||
cur_artist: str | None = None
|
||||
candidates: Sequence[autotag.AlbumMatch | autotag.TrackMatch] = []
|
||||
rec: Recommendation | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -166,11 +170,9 @@ class ImportTask(BaseImportTask):
|
|||
items: Iterable[library.Item] | None,
|
||||
):
|
||||
super().__init__(toppath, paths, items)
|
||||
self.rec = None
|
||||
self.should_remove_duplicates = False
|
||||
self.should_merge_duplicates = False
|
||||
self.is_album = True
|
||||
self.search_ids = [] # user-supplied candidate IDs.
|
||||
|
||||
def set_choice(
|
||||
self, choice: Action | autotag.AlbumMatch | autotag.TrackMatch
|
||||
|
|
@ -355,20 +357,17 @@ class ImportTask(BaseImportTask):
|
|||
tasks = [t for inner in tasks for t in inner]
|
||||
return tasks
|
||||
|
||||
def lookup_candidates(self):
|
||||
"""Retrieve and store candidates for this album. User-specified
|
||||
candidate IDs are stored in self.search_ids: if present, the
|
||||
initial lookup is restricted to only those IDs.
|
||||
"""
|
||||
artist, album, prop = autotag.tag_album(
|
||||
self.items, search_ids=self.search_ids
|
||||
)
|
||||
self.cur_artist = artist
|
||||
self.cur_album = album
|
||||
self.candidates = prop.candidates
|
||||
self.rec = prop.recommendation
|
||||
def lookup_candidates(self, search_ids: list[str]) -> None:
|
||||
"""Retrieve and store candidates for this album.
|
||||
|
||||
def find_duplicates(self, lib: library.Library):
|
||||
If User-specified ``search_ids`` list is not empty, the lookup is
|
||||
restricted to only those IDs.
|
||||
"""
|
||||
self.cur_artist, self.cur_album, (self.candidates, self.rec) = (
|
||||
autotag.tag_album(self.items, search_ids=search_ids)
|
||||
)
|
||||
|
||||
def find_duplicates(self, lib: library.Library) -> list[library.Album]:
|
||||
"""Return a list of albums from `lib` with the same artist and
|
||||
album name as the task.
|
||||
"""
|
||||
|
|
@ -520,9 +519,7 @@ class ImportTask(BaseImportTask):
|
|||
)
|
||||
replaced_album_ids = set()
|
||||
for item in self.imported_items():
|
||||
dup_items = list(
|
||||
lib.items(query=dbcore.query.BytesQuery("path", item.path))
|
||||
)
|
||||
dup_items = list(lib.items(query=PathQuery("path", item.path)))
|
||||
self.replaced_items[item] = dup_items
|
||||
for dup_item in dup_items:
|
||||
if (
|
||||
|
|
@ -698,12 +695,12 @@ class SingletonImportTask(ImportTask):
|
|||
for item in self.imported_items():
|
||||
plugins.send("item_imported", lib=lib, item=item)
|
||||
|
||||
def lookup_candidates(self):
|
||||
prop = autotag.tag_item(self.item, search_ids=self.search_ids)
|
||||
self.candidates = prop.candidates
|
||||
self.rec = prop.recommendation
|
||||
def lookup_candidates(self, search_ids: list[str]) -> None:
|
||||
self.candidates, self.rec = autotag.tag_item(
|
||||
self.item, search_ids=search_ids
|
||||
)
|
||||
|
||||
def find_duplicates(self, lib):
|
||||
def find_duplicates(self, lib: library.Library) -> list[library.Item]: # type: ignore[override] # Need splitting Singleton and Album tasks into separate classes
|
||||
"""Return a list of items from `lib` that have the same artist
|
||||
and title as the task.
|
||||
"""
|
||||
|
|
@ -805,6 +802,11 @@ class SentinelImportTask(ImportTask):
|
|||
pass
|
||||
|
||||
|
||||
ArchiveHandler = tuple[
|
||||
Callable[[util.StrPath], bool], Callable[[util.StrPath], Any]
|
||||
]
|
||||
|
||||
|
||||
class ArchiveImportTask(SentinelImportTask):
|
||||
"""An import task that represents the processing of an archive.
|
||||
|
||||
|
|
@ -830,13 +832,13 @@ class ArchiveImportTask(SentinelImportTask):
|
|||
if not os.path.isfile(path):
|
||||
return False
|
||||
|
||||
for path_test, _ in cls.handlers():
|
||||
for path_test, _ in cls.handlers:
|
||||
if path_test(os.fsdecode(path)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def handlers(cls):
|
||||
@util.cached_classproperty
|
||||
def handlers(cls) -> list[ArchiveHandler]:
|
||||
"""Returns a list of archive handlers.
|
||||
|
||||
Each handler is a `(path_test, ArchiveClass)` tuple. `path_test`
|
||||
|
|
@ -844,28 +846,27 @@ class ArchiveImportTask(SentinelImportTask):
|
|||
handled by `ArchiveClass`. `ArchiveClass` is a class that
|
||||
implements the same interface as `tarfile.TarFile`.
|
||||
"""
|
||||
if not hasattr(cls, "_handlers"):
|
||||
cls._handlers: list[tuple[Callable, ...]] = []
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
_handlers: list[ArchiveHandler] = []
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
|
||||
cls._handlers.append((is_zipfile, ZipFile))
|
||||
import tarfile
|
||||
_handlers.append((is_zipfile, ZipFile))
|
||||
import tarfile
|
||||
|
||||
cls._handlers.append((tarfile.is_tarfile, tarfile.open))
|
||||
try:
|
||||
from rarfile import RarFile, is_rarfile
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
cls._handlers.append((is_rarfile, RarFile))
|
||||
try:
|
||||
from py7zr import SevenZipFile, is_7zfile
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
cls._handlers.append((is_7zfile, SevenZipFile))
|
||||
_handlers.append((tarfile.is_tarfile, tarfile.open))
|
||||
try:
|
||||
from rarfile import RarFile, is_rarfile
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
_handlers.append((is_rarfile, RarFile))
|
||||
try:
|
||||
from py7zr import SevenZipFile, is_7zfile
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
_handlers.append((is_7zfile, SevenZipFile))
|
||||
|
||||
return cls._handlers
|
||||
return _handlers
|
||||
|
||||
def cleanup(self, copy=False, delete=False, move=False):
|
||||
"""Removes the temporary directory the archive was extracted to."""
|
||||
|
|
@ -882,7 +883,7 @@ class ArchiveImportTask(SentinelImportTask):
|
|||
"""
|
||||
assert self.toppath is not None, "toppath must be set"
|
||||
|
||||
for path_test, handler_class in self.handlers():
|
||||
for path_test, handler_class in self.handlers:
|
||||
if path_test(os.fsdecode(self.toppath)):
|
||||
break
|
||||
else:
|
||||
|
|
@ -928,7 +929,7 @@ class ImportTaskFactory:
|
|||
self.imported = 0 # "Real" tasks created.
|
||||
self.is_archive = ArchiveImportTask.is_archive(util.syspath(toppath))
|
||||
|
||||
def tasks(self):
|
||||
def tasks(self) -> Iterable[ImportTask]:
|
||||
"""Yield all import tasks for music found in the user-specified
|
||||
path `self.toppath`. Any necessary sentinel tasks are also
|
||||
produced.
|
||||
|
|
@ -1117,7 +1118,10 @@ def albums_in_dir(path: util.PathBytes):
|
|||
a list of Items that is probably an album. Specifically, any folder
|
||||
containing any media files is an album.
|
||||
"""
|
||||
collapse_pat = collapse_paths = collapse_items = None
|
||||
collapse_paths: list[util.PathBytes] = []
|
||||
collapse_items: list[util.PathBytes] = []
|
||||
collapse_pat = None
|
||||
|
||||
ignore: list[str] = config["ignore"].as_str_seq()
|
||||
ignore_hidden: bool = config["ignore_hidden"].get(bool)
|
||||
|
||||
|
|
@ -1142,7 +1146,7 @@ def albums_in_dir(path: util.PathBytes):
|
|||
# proceed to process the current one.
|
||||
if collapse_items:
|
||||
yield collapse_paths, collapse_items
|
||||
collapse_pat = collapse_paths = collapse_items = None
|
||||
collapse_pat, collapse_paths, collapse_items = None, [], []
|
||||
|
||||
# Check whether this directory looks like the *first* directory
|
||||
# in a multi-disc sequence. There are two indicators: the file
|
||||
|
|
|
|||
16
beets/library/__init__.py
Normal file
16
beets/library/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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
|
||||
|
||||
__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 "error reading " + str(super())
|
||||
|
||||
|
||||
class WriteError(FileOperationError):
|
||||
"""An error while writing a file (i.e. in `Item.write`)."""
|
||||
|
||||
def __str__(self):
|
||||
return "error writing " + str(super())
|
||||
148
beets/library/library.py
Normal file
148
beets/library/library.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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):
|
||||
"""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):
|
||||
"""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`.
|
||||
"""
|
||||
if isinstance(item_or_id, int):
|
||||
album_id = item_or_id
|
||||
else:
|
||||
album_id = item_or_id.album_id
|
||||
if album_id is None:
|
||||
return None
|
||||
return self._get(Album, album_id)
|
||||
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)
|
||||
397
beets/metadata_plugins.py
Normal file
397
beets/metadata_plugins.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""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 inspect
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Generic, Literal, Sequence, TypedDict, TypeVar
|
||||
|
||||
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 sys.version_info >= (3, 11):
|
||||
from typing import NotRequired
|
||||
else:
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from confuse import ConfigView
|
||||
|
||||
from .autotag import Distance
|
||||
from .autotag.hooks import AlbumInfo, Item, TrackInfo
|
||||
|
||||
|
||||
def find_metadata_source_plugins() -> list[MetadataSourcePlugin]:
|
||||
"""Returns a list of MetadataSourcePlugin subclass instances
|
||||
|
||||
Resolved from all currently loaded beets plugins.
|
||||
"""
|
||||
|
||||
all_plugins = find_plugins()
|
||||
metadata_plugins: list[MetadataSourcePlugin | BeetsPlugin] = []
|
||||
for plugin in all_plugins:
|
||||
if isinstance(plugin, MetadataSourcePlugin):
|
||||
metadata_plugins.append(plugin)
|
||||
elif hasattr(plugin, "data_source"):
|
||||
# TODO: Remove this in the future major release, v3.0.0
|
||||
warnings.warn(
|
||||
f"{plugin.__class__.__name__} is used as a legacy metadata source. "
|
||||
"It should extend MetadataSourcePlugin instead of BeetsPlugin. "
|
||||
"Support for this will be removed in the v3.0.0 release!",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
metadata_plugins.append(plugin)
|
||||
|
||||
# typeignore: BeetsPlugin is not a MetadataSourcePlugin (legacy support)
|
||||
return metadata_plugins # type: ignore[return-value]
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def track_distance(item: Item, info: TrackInfo) -> Distance:
|
||||
"""Returns the track distance for an item and trackinfo.
|
||||
|
||||
Returns a Distance object is populated by all metadata source plugins
|
||||
that implement the :py:meth:`MetadataSourcePlugin.track_distance` method.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
for plugin in find_metadata_source_plugins():
|
||||
dist.update(plugin.track_distance(item, info))
|
||||
return dist
|
||||
|
||||
|
||||
def album_distance(
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
"""Returns the album distance calculated by plugins."""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
for plugin in find_metadata_source_plugins():
|
||||
dist.update(plugin.album_distance(items, album_info, mapping))
|
||||
return dist
|
||||
|
||||
|
||||
def _get_distance(
|
||||
config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo
|
||||
) -> Distance:
|
||||
"""Returns the ``data_source`` weight and the maximum source weight
|
||||
for albums or individual tracks.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
if info.data_source == data_source:
|
||||
dist.add("source", config["source_weight"].as_number())
|
||||
return dist
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.config.add({"source_weight": 0.5})
|
||||
|
||||
@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 album_distance(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
"""Calculate the distance for an album based on its items and album info."""
|
||||
return _get_distance(
|
||||
data_source=self.data_source, info=album_info, config=self.config
|
||||
)
|
||||
|
||||
def track_distance(
|
||||
self,
|
||||
item: Item,
|
||||
info: TrackInfo,
|
||||
) -> Distance:
|
||||
"""Calculate the distance for a track based on its item and track info."""
|
||||
return _get_distance(
|
||||
data_source=self.data_source, info=info, config=self.config
|
||||
)
|
||||
|
||||
@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]
|
||||
|
||||
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 and strips trailing disambiguation numbers. 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]
|
||||
# Strip disambiguation number.
|
||||
name = re.sub(r" \(\d+\)$", "", name)
|
||||
# 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).
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal["album", "track"],
|
||||
filters: SearchFilter,
|
||||
keywords: 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 keywords: Additional keywords 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 = {"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}, keywords=title)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
return filter(
|
||||
None,
|
||||
self.tracks_for_ids([result["id"] for result in results if result]),
|
||||
)
|
||||
|
||||
|
||||
# Dynamically copy methods to BeetsPlugin for legacy support
|
||||
# TODO: Remove this in the future major release, v3.0.0
|
||||
|
||||
for name, method in inspect.getmembers(
|
||||
MetadataSourcePlugin, predicate=inspect.isfunction
|
||||
):
|
||||
if not hasattr(BeetsPlugin, name):
|
||||
setattr(BeetsPlugin, name, method)
|
||||
323
beets/plugins.py
323
beets/plugins.py
|
|
@ -23,21 +23,13 @@ import sys
|
|||
import traceback
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Sequence,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
)
|
||||
from types import GenericAlias
|
||||
from typing import TYPE_CHECKING, Any, Callable, Sequence, TypeVar
|
||||
|
||||
import mediafile
|
||||
|
||||
import beets
|
||||
from beets import logging
|
||||
from beets.util.id_extractors import extract_release_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.event_types import EventType
|
||||
|
|
@ -53,8 +45,6 @@ if TYPE_CHECKING:
|
|||
|
||||
from confuse import ConfigView
|
||||
|
||||
from beets.autotag import AlbumInfo, TrackInfo
|
||||
from beets.autotag.distance import Distance
|
||||
from beets.dbcore import Query
|
||||
from beets.dbcore.db import FieldQueryType
|
||||
from beets.dbcore.types import Type
|
||||
|
|
@ -114,7 +104,7 @@ class PluginLogFilter(logging.Filter):
|
|||
# Managing the plugins themselves.
|
||||
|
||||
|
||||
class BeetsPlugin:
|
||||
class BeetsPlugin(metaclass=abc.ABCMeta):
|
||||
"""The base class for all beets plugins. Plugins provide
|
||||
functionality by defining a subclass of BeetsPlugin and overriding
|
||||
the abstract methods defined here.
|
||||
|
|
@ -217,66 +207,6 @@ class BeetsPlugin:
|
|||
"""Return a dict mapping prefixes to Query subclasses."""
|
||||
return {}
|
||||
|
||||
def track_distance(
|
||||
self,
|
||||
item: Item,
|
||||
info: TrackInfo,
|
||||
) -> Distance:
|
||||
"""Should return a Distance object to be added to the
|
||||
distance for every track comparison.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
return Distance()
|
||||
|
||||
def album_distance(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
"""Should return a Distance object to be added to the
|
||||
distance for every album-level comparison.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
return Distance()
|
||||
|
||||
def candidates(
|
||||
self, items: list[Item], artist: str, album: str, va_likely: bool
|
||||
) -> Iterable[AlbumInfo]:
|
||||
"""Return :py:class:`AlbumInfo` candidates that match the given album.
|
||||
|
||||
: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
|
||||
"""
|
||||
yield from ()
|
||||
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
"""Return :py:class:`TrackInfo` candidates that match the given track.
|
||||
|
||||
:param item: Track item
|
||||
:param artist: Track artist
|
||||
:param title: Track title
|
||||
"""
|
||||
yield from ()
|
||||
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""Return an AlbumInfo object or None if no matching release was
|
||||
found.
|
||||
"""
|
||||
return None
|
||||
|
||||
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
||||
"""Return a TrackInfo object or None if no matching release was
|
||||
found.
|
||||
"""
|
||||
return None
|
||||
|
||||
def add_media_field(
|
||||
self, name: str, descriptor: mediafile.MediaField
|
||||
) -> None:
|
||||
|
|
@ -368,10 +298,13 @@ def load_plugins(names: Sequence[str] = ()) -> None:
|
|||
else:
|
||||
for obj in getattr(namespace, name).__dict__.values():
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
inspect.isclass(obj)
|
||||
and not isinstance(
|
||||
obj, GenericAlias
|
||||
) # seems to be needed for python <= 3.9 only
|
||||
and issubclass(obj, BeetsPlugin)
|
||||
and obj != BeetsPlugin
|
||||
and obj != MetadataSourcePlugin
|
||||
and not inspect.isabstract(obj)
|
||||
and obj not in _classes
|
||||
):
|
||||
_classes.add(obj)
|
||||
|
|
@ -429,7 +362,7 @@ def queries() -> dict[str, type[Query]]:
|
|||
|
||||
|
||||
def types(model_cls: type[AnyModel]) -> dict[str, Type]:
|
||||
# Gives us `item_types` and `album_types`
|
||||
"""Return mapping between flex field names and types for the given model."""
|
||||
attr_name = f"{model_cls.__name__.lower()}_types"
|
||||
types: dict[str, Type] = {}
|
||||
for plugin in find_plugins():
|
||||
|
|
@ -446,39 +379,13 @@ def types(model_cls: type[AnyModel]) -> dict[str, Type]:
|
|||
|
||||
|
||||
def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]:
|
||||
# Gather `item_queries` and `album_queries` from the plugins.
|
||||
"""Return mapping between field names and queries for the given model."""
|
||||
attr_name = f"{model_cls.__name__.lower()}_queries"
|
||||
queries: dict[str, FieldQueryType] = {}
|
||||
for plugin in find_plugins():
|
||||
plugin_queries = getattr(plugin, attr_name, {})
|
||||
queries.update(plugin_queries)
|
||||
return queries
|
||||
|
||||
|
||||
def track_distance(item: Item, info: TrackInfo) -> Distance:
|
||||
"""Gets the track distance calculated by all loaded plugins.
|
||||
Returns a Distance object.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
for plugin in find_plugins():
|
||||
dist.update(plugin.track_distance(item, info))
|
||||
return dist
|
||||
|
||||
|
||||
def album_distance(
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
"""Returns the album distance calculated by plugins."""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
for plugin in find_plugins():
|
||||
dist.update(plugin.album_distance(items, album_info, mapping))
|
||||
return dist
|
||||
return {
|
||||
field: query
|
||||
for plugin in find_plugins()
|
||||
for field, query in getattr(plugin, attr_name, {}).items()
|
||||
}
|
||||
|
||||
|
||||
def notify_info_yielded(event: str) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]:
|
||||
|
|
@ -501,46 +408,6 @@ def notify_info_yielded(event: str) -> Callable[[IterF[P, Ret]], IterF[P, Ret]]:
|
|||
return decorator
|
||||
|
||||
|
||||
@notify_info_yielded("albuminfo_received")
|
||||
def candidates(*args, **kwargs) -> Iterable[AlbumInfo]:
|
||||
"""Return matching album candidates from all plugins."""
|
||||
for plugin in find_plugins():
|
||||
yield from plugin.candidates(*args, **kwargs)
|
||||
|
||||
|
||||
@notify_info_yielded("trackinfo_received")
|
||||
def item_candidates(*args, **kwargs) -> Iterable[TrackInfo]:
|
||||
"""Return matching track candidates from all plugins."""
|
||||
for plugin in find_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_plugins():
|
||||
if info := plugin.album_for_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_plugins():
|
||||
if info := plugin.track_for_id(_id):
|
||||
send("trackinfo_received", info=info)
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def template_funcs() -> TFuncMap[str]:
|
||||
"""Get all the template functions declared by plugins as a
|
||||
dictionary.
|
||||
|
|
@ -655,20 +522,6 @@ def feat_tokens(for_artist: bool = True) -> str:
|
|||
)
|
||||
|
||||
|
||||
def get_distance(
|
||||
config: ConfigView, data_source: str, info: AlbumInfo | TrackInfo
|
||||
) -> Distance:
|
||||
"""Returns the ``data_source`` weight and the maximum source weight
|
||||
for albums or individual tracks.
|
||||
"""
|
||||
from beets.autotag.distance import Distance
|
||||
|
||||
dist = Distance()
|
||||
if info.data_source == data_source:
|
||||
dist.add("source", config["source_weight"].as_number())
|
||||
return dist
|
||||
|
||||
|
||||
def apply_item_changes(
|
||||
lib: Library, item: Item, move: bool, pretend: bool, write: bool
|
||||
) -> None:
|
||||
|
|
@ -694,149 +547,3 @@ def apply_item_changes(
|
|||
item.try_write()
|
||||
|
||||
item.store()
|
||||
|
||||
|
||||
class Response(TypedDict):
|
||||
"""A dictionary with the response of a plugin API call.
|
||||
|
||||
May be extended by plugins to include additional information, but `id`
|
||||
is required.
|
||||
"""
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
R = TypeVar("R", bound=Response)
|
||||
|
||||
|
||||
class MetadataSourcePlugin(Generic[R], BeetsPlugin, metaclass=abc.ABCMeta):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config.add({"source_weight": 0.5})
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def data_source(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def search_url(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def album_url(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def track_url(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: str,
|
||||
filters: dict[str, str] | None,
|
||||
keywords: str = "",
|
||||
) -> Sequence[R]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def get_artist(
|
||||
artists,
|
||||
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 and strips trailing disambiguation numbers. 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.
|
||||
:type artists: list[dict] or list[list]
|
||||
: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]
|
||||
# Strip disambiguation number.
|
||||
name = re.sub(r" \(\d+\)$", "", name)
|
||||
# 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
|
||||
|
||||
def _get_id(self, id_string: str) -> str | None:
|
||||
"""Parse release ID from the given ID string."""
|
||||
return extract_release_id(self.data_source.lower(), id_string)
|
||||
|
||||
def candidates(
|
||||
self, items: list[Item], artist: str, album: str, va_likely: bool
|
||||
) -> Iterable[AlbumInfo]:
|
||||
query_filters = {"album": album}
|
||||
if not va_likely:
|
||||
query_filters["artist"] = artist
|
||||
for result in self._search_api("album", query_filters):
|
||||
if info := self.album_for_id(result["id"]):
|
||||
yield info
|
||||
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[TrackInfo]:
|
||||
for result in self._search_api(
|
||||
"track", {"artist": artist}, keywords=title
|
||||
):
|
||||
if info := self.track_for_id(result["id"]):
|
||||
yield info
|
||||
|
||||
def album_distance(
|
||||
self,
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
return get_distance(
|
||||
data_source=self.data_source, info=album_info, config=self.config
|
||||
)
|
||||
|
||||
def track_distance(self, item: Item, info: TrackInfo) -> Distance:
|
||||
return get_distance(
|
||||
data_source=self.data_source, info=info, config=self.config
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -110,34 +111,6 @@ def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
|
|||
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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,12 +52,13 @@ import beets.plugins
|
|||
from beets import importer, logging, util
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.importer import ImportSession
|
||||
from beets.library import Album, Item, Library
|
||||
from beets.library import Item, Library
|
||||
from beets.test import _common
|
||||
from beets.ui.commands import TerminalImportSession
|
||||
from beets.util import (
|
||||
MoveOperation,
|
||||
bytestring_path,
|
||||
cached_classproperty,
|
||||
clean_module_tempdir,
|
||||
syspath,
|
||||
)
|
||||
|
|
@ -163,15 +164,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):
|
||||
|
|
@ -194,8 +229,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",
|
||||
{
|
||||
|
|
@ -205,9 +239,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())
|
||||
|
|
@ -215,12 +247,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()
|
||||
|
||||
|
|
@ -384,16 +412,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.
|
||||
|
|
@ -448,11 +472,6 @@ class PluginMixin(ConfigMixin):
|
|||
plugin: ClassVar[str]
|
||||
preload_plugin: ClassVar[bool] = True
|
||||
|
||||
original_item_types = dict(Item._types)
|
||||
original_album_types = dict(Album._types)
|
||||
original_item_queries = dict(Item._queries)
|
||||
original_album_queries = dict(Album._queries)
|
||||
|
||||
def setup_beets(self):
|
||||
super().setup_beets()
|
||||
if self.preload_plugin:
|
||||
|
|
@ -471,16 +490,11 @@ 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
|
||||
cached_classproperty.cache.clear()
|
||||
beets.plugins.load_plugins(plugins)
|
||||
beets.plugins.send("pluginload")
|
||||
beets.plugins.find_plugins()
|
||||
|
||||
# Take a backup of the original _types and _queries to restore
|
||||
# when unloading.
|
||||
Item._types.update(beets.plugins.types(Item))
|
||||
Album._types.update(beets.plugins.types(Album))
|
||||
Item._queries.update(beets.plugins.named_queries(Item))
|
||||
Album._queries.update(beets.plugins.named_queries(Album))
|
||||
|
||||
def unload_plugins(self) -> None:
|
||||
"""Unload all plugins and remove them from the configuration."""
|
||||
# FIXME this should eventually be handled by a plugin manager
|
||||
|
|
@ -489,10 +503,6 @@ class PluginMixin(ConfigMixin):
|
|||
self.config["plugins"] = []
|
||||
beets.plugins._classes = set()
|
||||
beets.plugins._instances = {}
|
||||
Item._types = self.original_item_types
|
||||
Album._types = self.original_album_types
|
||||
Item._queries = self.original_item_queries
|
||||
Album._queries = self.original_album_queries
|
||||
|
||||
@contextmanager
|
||||
def configure_plugin(self, config: Any):
|
||||
|
|
@ -514,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,
|
||||
|
|
@ -531,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
|
||||
|
||||
|
|
@ -599,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):
|
||||
|
|
@ -623,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):
|
||||
|
|
@ -759,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
|
||||
|
|
@ -792,10 +786,12 @@ class AutotagStub:
|
|||
|
||||
def install(self):
|
||||
self.patchers = [
|
||||
patch("beets.plugins.album_for_id", lambda *_: None),
|
||||
patch("beets.plugins.track_for_id", lambda *_: None),
|
||||
patch("beets.plugins.candidates", self.candidates),
|
||||
patch("beets.plugins.item_candidates", self.item_candidates),
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -104,30 +104,15 @@ def _stream_encoding(stream, default="utf-8"):
|
|||
return stream.encoding or default
|
||||
|
||||
|
||||
def decargs(arglist):
|
||||
"""Given a list of command-line argument bytestrings, attempts to
|
||||
decode them to Unicode strings when running under Python 2.
|
||||
"""
|
||||
return arglist
|
||||
|
||||
|
||||
def print_(*strings, **kwargs):
|
||||
def print_(*strings: str, end: str = "\n") -> None:
|
||||
"""Like print, but rather than raising an error when a character
|
||||
is not in the terminal's encoding's character set, just silently
|
||||
replaces it.
|
||||
|
||||
The arguments must be Unicode strings: `unicode` on Python 2; `str` on
|
||||
Python 3.
|
||||
|
||||
The `end` keyword argument behaves similarly to the built-in `print`
|
||||
(it defaults to a newline).
|
||||
"""
|
||||
if not strings:
|
||||
strings = [""]
|
||||
assert isinstance(strings[0], str)
|
||||
|
||||
txt = " ".join(strings)
|
||||
txt += kwargs.get("end", "\n")
|
||||
txt = " ".join(strings or ("",)) + end
|
||||
|
||||
# Encode the string and write it to stdout.
|
||||
# On Python 3, sys.stdout expects text strings and uses the
|
||||
|
|
@ -435,56 +420,6 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
|
|||
return []
|
||||
|
||||
|
||||
# Human output formatting.
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def human_seconds_short(interval):
|
||||
"""Formats a number of seconds as a short human-readable M:SS
|
||||
string.
|
||||
"""
|
||||
interval = int(interval)
|
||||
return "%i:%02i" % (interval // 60, interval % 60)
|
||||
|
||||
|
||||
# Colorization.
|
||||
|
||||
# ANSI terminal colorization code heavily inspired by pygments:
|
||||
|
|
@ -1358,14 +1293,9 @@ class CommonOptionsParser(optparse.OptionParser):
|
|||
setattr(parser.values, option.dest, True)
|
||||
|
||||
# Use the explicitly specified format, or the string from the option.
|
||||
if fmt:
|
||||
value = fmt
|
||||
elif value:
|
||||
(value,) = decargs([value])
|
||||
else:
|
||||
value = ""
|
||||
|
||||
value = fmt or value or ""
|
||||
parser.values.format = value
|
||||
|
||||
if target:
|
||||
config[target._format_config_key].set(value)
|
||||
else:
|
||||
|
|
@ -1679,17 +1609,6 @@ def _setup(options, lib=None):
|
|||
|
||||
plugins = _load_plugins(options, config)
|
||||
|
||||
# Add types and queries defined by plugins.
|
||||
plugin_types_album = plugins.types(library.Album)
|
||||
library.Album._types.update(plugin_types_album)
|
||||
item_types = plugin_types_album.copy()
|
||||
item_types.update(library.Item._types)
|
||||
item_types.update(plugins.types(library.Item))
|
||||
library.Item._types = item_types
|
||||
|
||||
library.Item._queries.update(plugins.named_queries(library.Item))
|
||||
library.Album._queries.update(plugins.named_queries(library.Album))
|
||||
|
||||
plugins.send("pluginload")
|
||||
|
||||
# Get the default subcommands.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import beets
|
|||
from beets import autotag, config, importer, library, logging, plugins, ui, util
|
||||
from beets.autotag import Recommendation, hooks
|
||||
from beets.ui import (
|
||||
decargs,
|
||||
input_,
|
||||
print_,
|
||||
print_column_layout,
|
||||
|
|
@ -43,6 +42,7 @@ from beets.util import (
|
|||
normpath,
|
||||
syspath,
|
||||
)
|
||||
from beets.util.units import human_bytes, human_seconds, human_seconds_short
|
||||
|
||||
from . import _store_dict
|
||||
|
||||
|
|
@ -541,8 +541,8 @@ class ChangeRepresentation:
|
|||
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"({ui.human_seconds_short(cur_length0)})"
|
||||
new_length = f"({ui.human_seconds_short(new_length0)})"
|
||||
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)
|
||||
|
|
@ -706,14 +706,14 @@ class AlbumChange(ChangeRepresentation):
|
|||
for track_info in self.match.extra_tracks:
|
||||
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
|
||||
if track_info.length:
|
||||
line += f" ({ui.human_seconds_short(track_info.length)})"
|
||||
line += f" ({human_seconds_short(track_info.length)})"
|
||||
print_(ui.colorize("text_warning", line))
|
||||
if self.match.extra_items:
|
||||
print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
|
||||
for item in self.match.extra_items:
|
||||
line = " ! {} (#{})".format(item.title, self.format_index(item))
|
||||
if item.length:
|
||||
line += " ({})".format(ui.human_seconds_short(item.length))
|
||||
line += " ({})".format(human_seconds_short(item.length))
|
||||
print_(ui.colorize("text_warning", line))
|
||||
|
||||
|
||||
|
|
@ -795,8 +795,8 @@ def summarize_items(items, singleton):
|
|||
round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth
|
||||
)
|
||||
summary_parts.append(sample_bits)
|
||||
summary_parts.append(ui.human_seconds_short(total_duration))
|
||||
summary_parts.append(ui.human_bytes(total_filesize))
|
||||
summary_parts.append(human_seconds_short(total_duration))
|
||||
summary_parts.append(human_bytes(total_filesize))
|
||||
|
||||
return ", ".join(summary_parts)
|
||||
|
||||
|
|
@ -1302,7 +1302,7 @@ class TerminalImportSession(importer.ImportSession):
|
|||
# The import command.
|
||||
|
||||
|
||||
def import_files(lib, paths, query):
|
||||
def import_files(lib, paths: list[bytes], query):
|
||||
"""Import the files in the given list of paths or matching the
|
||||
query.
|
||||
"""
|
||||
|
|
@ -1333,7 +1333,7 @@ def import_files(lib, paths, query):
|
|||
plugins.send("import", lib=lib, paths=paths)
|
||||
|
||||
|
||||
def import_func(lib, opts, args):
|
||||
def import_func(lib, opts, args: list[str]):
|
||||
config["import"].set_args(opts)
|
||||
|
||||
# Special case: --copy flag suppresses import_move (which would
|
||||
|
|
@ -1342,8 +1342,8 @@ def import_func(lib, opts, args):
|
|||
config["import"]["move"] = False
|
||||
|
||||
if opts.library:
|
||||
query = decargs(args)
|
||||
paths = []
|
||||
query = args
|
||||
byte_paths = []
|
||||
else:
|
||||
query = None
|
||||
paths = args
|
||||
|
|
@ -1355,15 +1355,11 @@ def import_func(lib, opts, args):
|
|||
if not paths and not paths_from_logfiles:
|
||||
raise ui.UserError("no path specified")
|
||||
|
||||
# On Python 2, we used to get filenames as raw bytes, which is
|
||||
# what we need. On Python 3, we need to undo the "helpful"
|
||||
# conversion to Unicode strings to get the real bytestring
|
||||
# filename.
|
||||
paths = [os.fsencode(p) for p in paths]
|
||||
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 paths:
|
||||
for path in byte_paths:
|
||||
if not os.path.exists(syspath(normpath(path))):
|
||||
raise ui.UserError(
|
||||
"no such file or directory: {}".format(
|
||||
|
|
@ -1384,14 +1380,14 @@ def import_func(lib, opts, args):
|
|||
)
|
||||
continue
|
||||
|
||||
paths.append(path)
|
||||
byte_paths.append(path)
|
||||
|
||||
# If all paths were read from a logfile, and none of them exist, throw
|
||||
# an error
|
||||
if not paths:
|
||||
raise ui.UserError("none of the paths are importable")
|
||||
|
||||
import_files(lib, paths, query)
|
||||
import_files(lib, byte_paths, query)
|
||||
|
||||
|
||||
import_cmd = ui.Subcommand(
|
||||
|
|
@ -1595,7 +1591,7 @@ def list_items(lib, query, album, fmt=""):
|
|||
|
||||
|
||||
def list_func(lib, opts, args):
|
||||
list_items(lib, decargs(args), opts.album)
|
||||
list_items(lib, args, opts.album)
|
||||
|
||||
|
||||
list_cmd = ui.Subcommand("list", help="query the library", aliases=("ls",))
|
||||
|
|
@ -1738,7 +1734,7 @@ def update_func(lib, opts, args):
|
|||
return
|
||||
update_items(
|
||||
lib,
|
||||
decargs(args),
|
||||
args,
|
||||
opts.album,
|
||||
ui.should_move(opts.move),
|
||||
opts.pretend,
|
||||
|
|
@ -1860,7 +1856,7 @@ def remove_items(lib, query, album, delete, force):
|
|||
|
||||
|
||||
def remove_func(lib, opts, args):
|
||||
remove_items(lib, decargs(args), opts.album, opts.delete, opts.force)
|
||||
remove_items(lib, args, opts.album, opts.delete, opts.force)
|
||||
|
||||
|
||||
remove_cmd = ui.Subcommand(
|
||||
|
|
@ -1906,7 +1902,7 @@ def show_stats(lib, query, exact):
|
|||
if item.album_id:
|
||||
albums.add(item.album_id)
|
||||
|
||||
size_str = "" + ui.human_bytes(total_size)
|
||||
size_str = "" + human_bytes(total_size)
|
||||
if exact:
|
||||
size_str += f" ({total_size} bytes)"
|
||||
|
||||
|
|
@ -1918,7 +1914,7 @@ Artists: {}
|
|||
Albums: {}
|
||||
Album artists: {}""".format(
|
||||
total_items,
|
||||
ui.human_seconds(total_time),
|
||||
human_seconds(total_time),
|
||||
f" ({total_time:.2f} seconds)" if exact else "",
|
||||
"Total size" if exact else "Approximate total size",
|
||||
size_str,
|
||||
|
|
@ -1930,7 +1926,7 @@ Album artists: {}""".format(
|
|||
|
||||
|
||||
def stats_func(lib, opts, args):
|
||||
show_stats(lib, decargs(args), opts.exact)
|
||||
show_stats(lib, args, opts.exact)
|
||||
|
||||
|
||||
stats_cmd = ui.Subcommand(
|
||||
|
|
@ -2058,7 +2054,7 @@ def modify_parse_args(args):
|
|||
|
||||
|
||||
def modify_func(lib, opts, args):
|
||||
query, mods, dels = modify_parse_args(decargs(args))
|
||||
query, mods, dels = modify_parse_args(args)
|
||||
if not mods and not dels:
|
||||
raise ui.UserError("no modifications specified")
|
||||
modify_items(
|
||||
|
|
@ -2126,12 +2122,20 @@ default_commands.append(modify_cmd)
|
|||
|
||||
|
||||
def move_items(
|
||||
lib, dest, query, copy, album, pretend, confirm=False, export=False
|
||||
lib,
|
||||
dest_path: util.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)
|
||||
|
|
@ -2216,7 +2220,7 @@ def move_func(lib, opts, args):
|
|||
move_items(
|
||||
lib,
|
||||
dest,
|
||||
decargs(args),
|
||||
args,
|
||||
opts.copy,
|
||||
opts.album,
|
||||
opts.pretend,
|
||||
|
|
@ -2297,7 +2301,7 @@ def write_items(lib, query, pretend, force):
|
|||
|
||||
|
||||
def write_func(lib, opts, args):
|
||||
write_items(lib, decargs(args), opts.pretend, opts.force)
|
||||
write_items(lib, args, opts.pretend, opts.force)
|
||||
|
||||
|
||||
write_cmd = ui.Subcommand("write", help="write tag information to files")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import sys
|
|||
import tempfile
|
||||
import traceback
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
from functools import cache
|
||||
|
|
@ -40,8 +41,8 @@ from typing import (
|
|||
Any,
|
||||
AnyStr,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Iterable,
|
||||
NamedTuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
|
|
@ -53,23 +54,18 @@ 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
|
||||
|
||||
from beets.library import Item
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else:
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -860,7 +856,9 @@ class CommandOutput(NamedTuple):
|
|||
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
|
||||
|
|
@ -878,8 +876,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(
|
||||
|
|
@ -894,7 +890,7 @@ 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)
|
||||
|
|
@ -1019,19 +1015,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.
|
||||
|
||||
|
|
@ -1070,20 +1053,46 @@ def par_map(transform: Callable[[T], Any], items: Sequence[T]) -> None:
|
|||
|
||||
|
||||
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.
|
||||
"""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):
|
||||
cache: ClassVar[dict[tuple[Any, str], Any]] = {}
|
||||
|
||||
name: str
|
||||
|
||||
# Ideally, we would like to use `Callable[[type[T]], Any]` here,
|
||||
# however, `mypy` is unable to see this as a **class** property, and thinks
|
||||
# that this callable receives an **instance** of the object, failing the
|
||||
# type check, for example:
|
||||
# >>> class Album:
|
||||
# >>> @cached_classproperty
|
||||
# >>> def foo(cls):
|
||||
# >>> reveal_type(cls) # mypy: revealed type is "Album"
|
||||
# >>> return cls.bar
|
||||
#
|
||||
# Argument 1 to "cached_classproperty" has incompatible type
|
||||
# "Callable[[Album], ...]"; expected "Callable[[type[Album]], ...]"
|
||||
#
|
||||
# Therefore, we just use `Any` here, which is not ideal, but works.
|
||||
def __init__(self, getter: Callable[[Any], Any]) -> None:
|
||||
"""Initialize the descriptor with the property getter function."""
|
||||
self.getter = getter
|
||||
self.cache = {}
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if owner not in self.cache:
|
||||
self.cache[owner] = self.getter(owner)
|
||||
def __set_name__(self, owner: Any, name: str) -> None:
|
||||
"""Capture the attribute name this descriptor is assigned to."""
|
||||
self.name = name
|
||||
|
||||
return self.cache[owner]
|
||||
def __get__(self, instance: Any, owner: type[Any]) -> Any:
|
||||
"""Compute and cache if needed, and return the property value."""
|
||||
key = owner, self.name
|
||||
if key not in self.cache:
|
||||
self.cache[key] = self.getter(owner)
|
||||
|
||||
return self.cache[key]
|
||||
|
||||
|
||||
class LazySharedInstance(Generic[T]):
|
||||
|
|
|
|||
|
|
@ -214,9 +214,9 @@ class IMBackend(LocalBackend):
|
|||
else:
|
||||
return cls._version
|
||||
|
||||
convert_cmd: list[str | bytes]
|
||||
identify_cmd: list[str | bytes]
|
||||
compare_cmd: list[str | bytes]
|
||||
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.
|
||||
|
|
@ -265,7 +265,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: list[str | bytes] = self.convert_cmd + [
|
||||
cmd: list[str] = self.convert_cmd + [
|
||||
syspath(path_in, prefix=False),
|
||||
"-resize",
|
||||
f"{maxwidth}x>",
|
||||
|
|
@ -295,7 +295,7 @@ class IMBackend(LocalBackend):
|
|||
return path_out
|
||||
|
||||
def get_size(self, path_in: bytes) -> tuple[int, int] | None:
|
||||
cmd: list[str | bytes] = self.identify_cmd + [
|
||||
cmd: list[str] = self.identify_cmd + [
|
||||
"-format",
|
||||
"%w %h",
|
||||
syspath(path_in, prefix=False),
|
||||
|
|
@ -480,10 +480,11 @@ class IMBackend(LocalBackend):
|
|||
return True
|
||||
|
||||
def write_metadata(self, file: bytes, metadata: Mapping[str, str]) -> None:
|
||||
assignments = list(
|
||||
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
|
||||
from beets import logging
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
PATTERN_BY_SOURCE = {
|
||||
"spotify": re.compile(r"(?:^|open\.spotify\.com/[^/]+/)([0-9A-Za-z]{22})"),
|
||||
"deezer": re.compile(r"(?:^|deezer\.com/)(?:[a-z]*/)?(?:[^/]+/)?(\d+)"),
|
||||
|
|
@ -43,6 +48,21 @@ PATTERN_BY_SOURCE = {
|
|||
|
||||
|
||||
def extract_release_id(source: str, id_: str) -> str | None:
|
||||
if m := PATTERN_BY_SOURCE[source].search(str(id_)):
|
||||
"""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(
|
||||
f"Unknown source '{source}' for ID extraction. Returning id/url as-is."
|
||||
)
|
||||
return id_
|
||||
|
||||
if m := source_pattern.search(str(id_)):
|
||||
return m[1]
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ 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.
|
||||
|
|
|
|||
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 "%i:%02i" % (interval // 60, interval % 60)
|
||||
|
||||
|
||||
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"
|
||||
|
|
@ -137,7 +137,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,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]):
|
||||
|
|
@ -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,19 @@
|
|||
|
||||
"""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,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Literal,
|
||||
Sequence,
|
||||
overload,
|
||||
)
|
||||
|
||||
import confuse
|
||||
from requests_oauthlib import OAuth1Session
|
||||
|
|
@ -29,7 +39,13 @@ 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.metadata_plugins import MetadataSourcePlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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/"
|
||||
|
|
@ -39,20 +55,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"
|
||||
|
||||
|
|
@ -77,7 +79,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
|
||||
|
|
@ -99,15 +101,13 @@ 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
|
||||
|
|
@ -117,20 +117,37 @@ class BeatportClient:
|
|||
)
|
||||
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",
|
||||
|
|
@ -140,20 +157,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:
|
||||
|
|
@ -162,35 +177,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
|
||||
|
||||
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
|
||||
|
|
@ -211,48 +224,81 @@ class BeatportClient:
|
|||
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)
|
||||
class BeatportObject:
|
||||
beatport_id: str
|
||||
name: str
|
||||
|
||||
release_date: datetime | None = None
|
||||
|
||||
artists: list[tuple[str, str]] | None = None
|
||||
# tuple of artist id and artist name
|
||||
|
||||
def __init__(self, data: JSONDict):
|
||||
self.beatport_id = str(data["id"]) # given as int in the response
|
||||
self.name = str(data["name"])
|
||||
if "releaseDate" in data:
|
||||
self.release_date = datetime.strptime(
|
||||
data["releaseDate"], "%Y-%m-%d"
|
||||
)
|
||||
if "artists" in data:
|
||||
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
|
||||
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 = "Various Artists"
|
||||
return "<BeatportRelease: {} - {} ({})>".format(
|
||||
artist_str,
|
||||
self.name,
|
||||
self.catalog_number,
|
||||
)
|
||||
artist_str = None
|
||||
|
||||
def __repr__(self):
|
||||
return str(self).encode("utf-8")
|
||||
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")
|
||||
|
||||
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"]
|
||||
)
|
||||
self.genre = data.get("genre")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<BeatportRelease: {} - {} ({})>".format(
|
||||
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:
|
||||
|
|
@ -279,8 +325,8 @@ class BeatportTrack(BeatportObject):
|
|||
self.genre = str(data["genres"][0].get("name"))
|
||||
|
||||
|
||||
class BeatportPlugin(BeetsPlugin):
|
||||
data_source = "Beatport"
|
||||
class BeatportPlugin(MetadataSourcePlugin):
|
||||
_client: BeatportClient | None = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -294,12 +340,19 @@ class BeatportPlugin(BeetsPlugin):
|
|||
)
|
||||
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:
|
||||
|
|
@ -312,9 +365,9 @@ 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:
|
||||
|
|
@ -341,44 +394,30 @@ class BeatportPlugin(BeetsPlugin):
|
|||
|
||||
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):
|
||||
"""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 []
|
||||
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)
|
||||
|
|
@ -386,13 +425,13 @@ class BeatportPlugin(BeetsPlugin):
|
|||
self._log.debug("API Error: {0} (query: {1})", 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 {0}", album_id)
|
||||
|
||||
if not (release_id := self._get_id(release_id)):
|
||||
if not (release_id := self._extract_id(album_id)):
|
||||
self._log.debug("Not a valid Beatport release ID.")
|
||||
return None
|
||||
|
||||
|
|
@ -401,11 +440,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)
|
||||
# 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.")
|
||||
|
|
@ -415,7 +455,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
|
||||
|
|
@ -425,16 +465,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,
|
||||
|
|
@ -445,18 +491,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":
|
||||
|
|
@ -482,9 +528,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."""
|
||||
|
|
|
|||
|
|
@ -125,7 +125,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,7 +30,7 @@ from typing import TYPE_CHECKING
|
|||
|
||||
import beets
|
||||
import beets.ui
|
||||
from beets import dbcore, vfs
|
||||
from beets import dbcore, logging, vfs
|
||||
from beets.library import Item
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import as_string, bluelet
|
||||
|
|
@ -38,6 +38,17 @@ from beets.util import as_string, bluelet
|
|||
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
|
||||
|
||||
|
|
@ -94,11 +105,6 @@ SUBSYSTEMS = [
|
|||
]
|
||||
|
||||
|
||||
# Gstreamer import error.
|
||||
class NoGstreamerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Error-handling, exceptions, parameter parsing.
|
||||
|
||||
|
||||
|
|
@ -1099,14 +1105,6 @@ 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
|
||||
|
|
@ -1616,16 +1614,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(
|
||||
|
|
|
|||
|
|
@ -63,9 +63,8 @@ class BPMPlugin(BeetsPlugin):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ autotagger. Requires the pyacoustid library.
|
|||
import re
|
||||
from collections import defaultdict
|
||||
from functools import cached_property, partial
|
||||
from typing import Iterable
|
||||
|
||||
import acoustid
|
||||
import confuse
|
||||
|
||||
from beets import config, plugins, ui, util
|
||||
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"
|
||||
|
|
@ -168,10 +171,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,
|
||||
|
|
@ -210,7 +212,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
self._log.debug("acoustid album candidates: {0}", 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 []
|
||||
|
||||
|
|
@ -223,6 +225,14 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
self._log.debug("acoustid item candidates: {0}", 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"
|
||||
|
|
@ -233,7 +243,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
|
||||
|
||||
|
|
@ -242,7 +252,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
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
encode_cmd.append(os.fsdecode(args[i]))
|
||||
|
||||
if pretend:
|
||||
self._log.info("{0}", " ".join(ui.decargs(args)))
|
||||
self._log.info("{0}", " ".join(args))
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -323,9 +323,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
raise
|
||||
except OSError as exc:
|
||||
raise ui.UserError(
|
||||
"convert: couldn't invoke '{}': {}".format(
|
||||
" ".join(ui.decargs(args)), exc
|
||||
)
|
||||
"convert: couldn't invoke '{}': {}".format(" ".join(args), exc)
|
||||
)
|
||||
|
||||
if not quiet and not pretend:
|
||||
|
|
@ -579,13 +577,13 @@ class ConvertPlugin(BeetsPlugin):
|
|||
) = 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, ""))
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from __future__ import annotations
|
|||
|
||||
import collections
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Literal, Sequence
|
||||
|
||||
import requests
|
||||
import unidecode
|
||||
|
|
@ -25,58 +26,47 @@ 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.metadata_plugins import (
|
||||
IDResponse,
|
||||
SearchApiMetadataSourcePlugin,
|
||||
SearchFilter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def commands(self):
|
||||
"""Add beet UI commands to interact with Deezer."""
|
||||
deezer_update_cmd = ui.Subcommand(
|
||||
"deezerupdate", help=f"Update {self.data_source} rank"
|
||||
)
|
||||
|
||||
def func(lib, 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)
|
||||
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: str) -> AlbumInfo | None:
|
||||
"""Fetch an album by its Deezer ID or URL."""
|
||||
if not (deezer_id := self._get_id(album_id)):
|
||||
if not (deezer_id := self._extract_id(album_id)):
|
||||
return None
|
||||
|
||||
album_url = f"{self.album_url}{deezer_id}"
|
||||
|
|
@ -157,13 +147,54 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
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(
|
||||
self.album_url + str(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"]])
|
||||
|
|
@ -185,63 +216,17 @@ 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:
|
||||
if not (deezer_id := self._get_id(track_id)) or not (
|
||||
track_data := self.fetch_data(f"{self.track_url}{deezer_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).
|
||||
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=""):
|
||||
def _construct_search_query(
|
||||
filters: SearchFilter, keywords: str = ""
|
||||
) -> str:
|
||||
"""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 filters: Field filters to apply.
|
||||
: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,
|
||||
|
|
@ -252,25 +237,30 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
query = query.decode("utf8")
|
||||
return unidecode.unidecode(query)
|
||||
|
||||
def _search_api(self, query_type, filters=None, keywords=""):
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal[
|
||||
"album",
|
||||
"track",
|
||||
"artist",
|
||||
"history",
|
||||
"playlist",
|
||||
"podcast",
|
||||
"radio",
|
||||
"user",
|
||||
],
|
||||
filters: SearchFilter,
|
||||
keywords="",
|
||||
) -> Sequence[IDResponse]:
|
||||
"""Query the Deezer Search API for the specified ``keywords``, 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 keywords: Query keywords to use.
|
||||
: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}'")
|
||||
try:
|
||||
response = requests.get(
|
||||
|
|
@ -285,8 +275,8 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
self.data_source,
|
||||
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 '{}'",
|
||||
len(response_data),
|
||||
|
|
@ -295,7 +285,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
)
|
||||
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(
|
||||
|
|
@ -321,3 +311,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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import time
|
|||
import traceback
|
||||
from functools import cache
|
||||
from string import ascii_lowercase
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Sequence
|
||||
|
||||
import confuse
|
||||
from discogs_client import Client, Master, Release
|
||||
|
|
@ -40,8 +40,7 @@ import beets.ui
|
|||
from beets import config
|
||||
from beets.autotag.distance import string_dist
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
|
||||
from beets.util.id_extractors import extract_release_id
|
||||
from beets.metadata_plugins import MetadataSourcePlugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
|
|
@ -84,7 +83,7 @@ class ReleaseFormat(TypedDict):
|
|||
descriptions: list[str] | None
|
||||
|
||||
|
||||
class DiscogsPlugin(BeetsPlugin):
|
||||
class DiscogsPlugin(MetadataSourcePlugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config.add(
|
||||
|
|
@ -169,20 +168,8 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
|
||||
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 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: list[Item], artist: str, album: str, va_likely: bool
|
||||
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)
|
||||
|
||||
|
|
@ -217,7 +204,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
"""
|
||||
self._log.debug("Searching for release {0}", album_id)
|
||||
|
||||
discogs_id = extract_release_id("discogs", album_id)
|
||||
discogs_id = self._extract_id(album_id)
|
||||
|
||||
if not discogs_id:
|
||||
return None
|
||||
|
|
@ -272,7 +259,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
return map(self.get_album_info, releases)
|
||||
return filter(None, map(self.get_album_info, releases))
|
||||
|
||||
@cache
|
||||
def get_master_year(self, master_id: str) -> int | None:
|
||||
|
|
@ -334,7 +321,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
self._log.warning("Release does not contain the required fields")
|
||||
return None
|
||||
|
||||
artist, artist_id = MetadataSourcePlugin.get_artist(
|
||||
artist, artist_id = self.get_artist(
|
||||
[a.data for a in result.artists], join_key="join"
|
||||
)
|
||||
album = re.sub(r" +", " ", result.title)
|
||||
|
|
@ -359,7 +346,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
else:
|
||||
genre = base_genre
|
||||
|
||||
discogs_albumid = extract_release_id("discogs", 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.
|
||||
|
|
@ -419,7 +406,7 @@ 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,
|
||||
|
|
@ -638,7 +625,7 @@ 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(
|
||||
artist, artist_id = self.get_artist(
|
||||
track.get("artists", []), join_key="join"
|
||||
)
|
||||
length = self.get_track_length(track["duration"])
|
||||
|
|
|
|||
|
|
@ -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,6 +149,7 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
copy = bytestring_path(self.config["copy"].as_str())
|
||||
count = self.config["count"].get(bool)
|
||||
delete = self.config["delete"].get(bool)
|
||||
remove = self.config["remove"].get(bool)
|
||||
fmt = self.config["format"].get(str)
|
||||
full = self.config["full"].get(bool)
|
||||
keys = self.config["keys"].as_str_seq()
|
||||
|
|
@ -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.
|
||||
|
|
@ -196,6 +205,7 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
copy=copy,
|
||||
move=move,
|
||||
delete=delete,
|
||||
remove=remove,
|
||||
tag=tag,
|
||||
fmt=fmt.format(obj_count),
|
||||
)
|
||||
|
|
@ -204,7 +214,14 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
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 +233,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("=")
|
||||
|
|
|
|||
|
|
@ -180,8 +180,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.")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import requests
|
|||
|
||||
from beets import art, 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
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
)
|
||||
)
|
||||
|
||||
items = lib.items(decargs(args))
|
||||
items = lib.items(args)
|
||||
|
||||
# Confirm with user.
|
||||
if not opts.yes and not _confirm(items, not opts.file):
|
||||
|
|
@ -151,7 +151,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
except Exception as e:
|
||||
self._log.error("Unable to save image: {}".format(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
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class ExportPlugin(BeetsPlugin):
|
|||
items = []
|
||||
for data_emitter in data_collector(
|
||||
lib,
|
||||
ui.decargs(args),
|
||||
args,
|
||||
album=opts.album,
|
||||
):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1503,9 +1503,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
)
|
||||
|
||||
def func(lib: Library, opts, args) -> None:
|
||||
self.batch_fetch_art(
|
||||
lib, lib.albums(ui.decargs(args)), opts.force, opts.quiet
|
||||
)
|
||||
self.batch_fetch_art(lib, lib.albums(args), opts.force, opts.quiet)
|
||||
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
|
||||
write = ui.should_write()
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
for item in lib.items(args):
|
||||
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
|
||||
item.store()
|
||||
if write:
|
||||
|
|
|
|||
|
|
@ -14,27 +14,21 @@
|
|||
|
||||
"""Allows custom commands to be run when an event is emitted by beets"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
|
||||
class CodingFormatter(string.Formatter):
|
||||
"""A variant of `string.Formatter` that converts everything to `unicode`
|
||||
strings.
|
||||
class BytesToStrFormatter(string.Formatter):
|
||||
"""A variant of `string.Formatter` that converts `bytes` to `str`."""
|
||||
|
||||
This was necessary on Python 2, in needs to be kept for backwards
|
||||
compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self, coding):
|
||||
"""Creates a new coding formatter with the provided coding."""
|
||||
self._coding = coding
|
||||
|
||||
def convert_field(self, value, conversion):
|
||||
def convert_field(self, value: Any, conversion: str | None) -> Any:
|
||||
"""Converts the provided value given a conversion type.
|
||||
|
||||
This method decodes the converted value using the formatter's coding.
|
||||
|
|
@ -42,7 +36,7 @@ class CodingFormatter(string.Formatter):
|
|||
converted = super().convert_field(value, conversion)
|
||||
|
||||
if isinstance(converted, bytes):
|
||||
return converted.decode(self._coding)
|
||||
return os.fsdecode(converted)
|
||||
|
||||
return converted
|
||||
|
||||
|
|
@ -72,8 +66,8 @@ class HookPlugin(BeetsPlugin):
|
|||
return
|
||||
|
||||
# For backwards compatibility, use a string formatter that decodes
|
||||
# bytes (in particular, paths) to unicode strings.
|
||||
formatter = CodingFormatter(sys.getfilesystemencoding())
|
||||
# bytes (in particular, paths) to strings.
|
||||
formatter = BytesToStrFormatter()
|
||||
command_pieces = [
|
||||
formatter.format(piece, event=event, **kwargs)
|
||||
for piece in shlex.split(command)
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ class InfoPlugin(BeetsPlugin):
|
|||
summary = {}
|
||||
for data_emitter in data_collector(
|
||||
lib,
|
||||
ui.decargs(args),
|
||||
args,
|
||||
album=opts.album,
|
||||
):
|
||||
try:
|
||||
|
|
@ -232,7 +232,7 @@ class InfoPlugin(BeetsPlugin):
|
|||
if opts.keys_only:
|
||||
print_data_keys(data, item)
|
||||
else:
|
||||
fmt = ui.decargs([opts.format])[0] if opts.format else None
|
||||
fmt = [opts.format][0] if opts.format else None
|
||||
print_data(data, item, fmt)
|
||||
first = False
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
|
||||
def func(lib, opts, args):
|
||||
if opts.add:
|
||||
for album in lib.albums(ui.decargs(args)):
|
||||
for album in lib.albums(args):
|
||||
if len(album.items()) == 0:
|
||||
self._log.info(
|
||||
"{0} does not contain items, aborting", album
|
||||
|
|
@ -84,19 +84,19 @@ class IPFSPlugin(BeetsPlugin):
|
|||
album.store()
|
||||
|
||||
if opts.get:
|
||||
self.ipfs_get(lib, ui.decargs(args))
|
||||
self.ipfs_get(lib, args)
|
||||
|
||||
if opts.publish:
|
||||
self.ipfs_publish(lib)
|
||||
|
||||
if opts._import:
|
||||
self.ipfs_import(lib, ui.decargs(args))
|
||||
self.ipfs_import(lib, args)
|
||||
|
||||
if opts._list:
|
||||
self.ipfs_list(lib, ui.decargs(args))
|
||||
self.ipfs_list(lib, args)
|
||||
|
||||
if opts.play:
|
||||
self.ipfs_play(lib, opts, ui.decargs(args))
|
||||
self.ipfs_play(lib, opts, args)
|
||||
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class KeyFinderPlugin(BeetsPlugin):
|
|||
return [cmd]
|
||||
|
||||
def command(self, lib, opts, args):
|
||||
self.find_key(lib.items(ui.decargs(args)), write=ui.should_write())
|
||||
self.find_key(lib.items(args), write=ui.should_write())
|
||||
|
||||
def imported(self, session, task):
|
||||
self.find_key(task.imported_items())
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
label = "album"
|
||||
|
||||
if not new_genres and "artist" in self.sources:
|
||||
new_genres = None
|
||||
new_genres = []
|
||||
if isinstance(obj, library.Item):
|
||||
new_genres = self.fetch_artist_genre(obj)
|
||||
label = "artist"
|
||||
|
|
@ -521,7 +521,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
if opts.album:
|
||||
# Fetch genres for whole albums
|
||||
for album in lib.albums(ui.decargs(args)):
|
||||
for album in lib.albums(args):
|
||||
album.genre, src = self._get_genre(album)
|
||||
self._log.info(
|
||||
'genre for album "{0.album}" ({1}): {0.genre}',
|
||||
|
|
@ -550,7 +550,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
else:
|
||||
# Just query singletons, i.e. items that are not part of
|
||||
# an album
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
for item in lib.items(args):
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
self._log.info(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from itertools import islice
|
|||
|
||||
from beets.dbcore import FieldQuery
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs, print_
|
||||
from beets.ui import Subcommand, print_
|
||||
|
||||
|
||||
def lslimit(lib, opts, args):
|
||||
|
|
@ -36,11 +36,10 @@ def lslimit(lib, opts, args):
|
|||
if (opts.head or opts.tail or 0) < 0:
|
||||
raise ValueError("Limit value must be non-negative")
|
||||
|
||||
query = decargs(args)
|
||||
if opts.album:
|
||||
objs = lib.albums(query)
|
||||
objs = lib.albums(args)
|
||||
else:
|
||||
objs = lib.items(query)
|
||||
objs = lib.items(args)
|
||||
|
||||
if opts.head is not None:
|
||||
objs = islice(objs, opts.head)
|
||||
|
|
|
|||
|
|
@ -70,10 +70,14 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
|
|||
if not collections["collection-list"]:
|
||||
raise ui.UserError("no collections exist for user")
|
||||
|
||||
# Get all collection IDs, avoiding event collections
|
||||
collection_ids = [x["id"] for x in collections["collection-list"]]
|
||||
# Get all release collection IDs, avoiding event collections
|
||||
collection_ids = [
|
||||
x["id"]
|
||||
for x in collections["collection-list"]
|
||||
if x["entity-type"] == "release"
|
||||
]
|
||||
if not collection_ids:
|
||||
raise ui.UserError("No collection found.")
|
||||
raise ui.UserError("No release collection found.")
|
||||
|
||||
# Check that the collection exists so we can present a nice error
|
||||
collection = self.config["collection"].as_str()
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class MBSubmitPlugin(BeetsPlugin):
|
|||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self._mbsubmit(items)
|
||||
|
||||
mbsubmit_cmd.func = func
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
from collections import defaultdict
|
||||
|
||||
from beets import autotag, library, plugins, ui, util
|
||||
from beets import autotag, library, metadata_plugins, ui, util
|
||||
from beets.plugins import BeetsPlugin, apply_item_changes
|
||||
|
||||
|
||||
|
|
@ -63,10 +63,9 @@ class MBSyncPlugin(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
|
||||
|
|
@ -79,7 +78,9 @@ class MBSyncPlugin(BeetsPlugin):
|
|||
)
|
||||
continue
|
||||
|
||||
if not (track_info := plugins.track_for_id(item.mb_trackid)):
|
||||
if not (
|
||||
track_info := metadata_plugins.track_for_id(item.mb_trackid)
|
||||
):
|
||||
self._log.info(
|
||||
"Recording ID not found: {0.mb_trackid} for track {0}", item
|
||||
)
|
||||
|
|
@ -100,7 +101,9 @@ class MBSyncPlugin(BeetsPlugin):
|
|||
self._log.info("Skipping album with no mb_albumid: {}", album)
|
||||
continue
|
||||
|
||||
if not (album_info := plugins.album_for_id(album.mb_albumid)):
|
||||
if not (
|
||||
album_info := metadata_plugins.album_for_id(album.mb_albumid)
|
||||
):
|
||||
self._log.info(
|
||||
"Release ID {0.mb_albumid} not found for album {0}", album
|
||||
)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ class MetaSyncPlugin(BeetsPlugin):
|
|||
def func(self, lib, opts, args):
|
||||
"""Command handler for the metasync function."""
|
||||
pretend = opts.pretend
|
||||
query = ui.decargs(args)
|
||||
|
||||
sources = []
|
||||
for source in opts.sources:
|
||||
|
|
@ -106,7 +105,7 @@ class MetaSyncPlugin(BeetsPlugin):
|
|||
sources = sources or self.config["source"].as_str_seq()
|
||||
|
||||
meta_source_instances = {}
|
||||
items = lib.items(query)
|
||||
items = lib.items(args)
|
||||
|
||||
# Avoid needlessly instantiating meta sources (can be expensive)
|
||||
if not items:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ from time import mktime
|
|||
from xml.sax.saxutils import quoteattr
|
||||
|
||||
from beets.dbcore import types
|
||||
from beets.library import DateType
|
||||
from beets.util import displayable_path
|
||||
from beetsplug.metasync import MetaSource
|
||||
|
||||
|
|
@ -41,8 +40,8 @@ class Amarok(MetaSource):
|
|||
"amarok_score": types.FLOAT,
|
||||
"amarok_uid": types.STRING,
|
||||
"amarok_playcount": types.INTEGER,
|
||||
"amarok_firstplayed": DateType(),
|
||||
"amarok_lastplayed": DateType(),
|
||||
"amarok_firstplayed": types.DATE,
|
||||
"amarok_lastplayed": types.DATE,
|
||||
}
|
||||
|
||||
query_xml = '<query version="1.0"> \
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from confuse import ConfigValueError
|
|||
|
||||
from beets import util
|
||||
from beets.dbcore import types
|
||||
from beets.library import DateType
|
||||
from beets.util import bytestring_path, syspath
|
||||
from beetsplug.metasync import MetaSource
|
||||
|
||||
|
|
@ -63,9 +62,9 @@ class Itunes(MetaSource):
|
|||
"itunes_rating": types.INTEGER, # 0..100 scale
|
||||
"itunes_playcount": types.INTEGER,
|
||||
"itunes_skipcount": types.INTEGER,
|
||||
"itunes_lastplayed": DateType(),
|
||||
"itunes_lastskipped": DateType(),
|
||||
"itunes_dateadded": DateType(),
|
||||
"itunes_lastplayed": types.DATE,
|
||||
"itunes_lastskipped": types.DATE,
|
||||
"itunes_dateadded": types.DATE,
|
||||
}
|
||||
|
||||
def __init__(self, config, log):
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ from collections.abc import Iterator
|
|||
import musicbrainzngs
|
||||
from musicbrainzngs.musicbrainz import MusicBrainzError
|
||||
|
||||
from beets import config, plugins
|
||||
from beets import config, metadata_plugins
|
||||
from beets.dbcore import types
|
||||
from beets.library import Album, Item, Library
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs, print_
|
||||
from beets.ui import Subcommand, print_
|
||||
|
||||
MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$"
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ class MissingPlugin(BeetsPlugin):
|
|||
albms = self.config["album"].get()
|
||||
|
||||
helper = self._missing_albums if albms else self._missing_tracks
|
||||
helper(lib, decargs(args))
|
||||
helper(lib, args)
|
||||
|
||||
self._command.func = _miss
|
||||
return [self._command]
|
||||
|
|
@ -222,7 +222,7 @@ class MissingPlugin(BeetsPlugin):
|
|||
item_mbids = {x.mb_trackid for x in album.items()}
|
||||
# fetch missing items
|
||||
# TODO: Implement caching that without breaking other stuff
|
||||
if album_info := plugins.album_for_id(album.mb_albumid):
|
||||
if album_info := metadata_plugins.album_for_id(album.mb_albumid):
|
||||
for track_info in album_info.tracks:
|
||||
if track_info.track_id not in item_mbids:
|
||||
self._log.debug(
|
||||
|
|
|
|||
|
|
@ -18,14 +18,16 @@ import time
|
|||
|
||||
import mpd
|
||||
|
||||
from beets import config, library, plugins, ui
|
||||
from beets import config, plugins, ui
|
||||
from beets.dbcore import types
|
||||
from beets.dbcore.query import PathQuery
|
||||
from beets.util import displayable_path
|
||||
|
||||
# If we lose the connection, how many times do we want to retry and how
|
||||
# much time should we wait between retries?
|
||||
RETRIES = 10
|
||||
RETRY_INTERVAL = 5
|
||||
DUPLICATE_PLAY_THRESHOLD = 10.0
|
||||
|
||||
|
||||
mpd_config = config["mpd"]
|
||||
|
|
@ -142,7 +144,9 @@ class MPDStats:
|
|||
|
||||
self.do_rating = mpd_config["rating"].get(bool)
|
||||
self.rating_mix = mpd_config["rating_mix"].get(float)
|
||||
self.time_threshold = 10.0 # TODO: maybe add config option?
|
||||
self.played_ratio_threshold = mpd_config["played_ratio_threshold"].get(
|
||||
float
|
||||
)
|
||||
|
||||
self.now_playing = None
|
||||
self.mpd = MPDClientWrapper(log)
|
||||
|
|
@ -160,7 +164,7 @@ class MPDStats:
|
|||
|
||||
def get_item(self, path):
|
||||
"""Return the beets item related to path."""
|
||||
query = library.PathQuery("path", path)
|
||||
query = PathQuery("path", path)
|
||||
item = self.lib.items(query).get()
|
||||
if item:
|
||||
return item
|
||||
|
|
@ -215,10 +219,8 @@ class MPDStats:
|
|||
|
||||
Returns whether the change was manual (skipped previous song or not)
|
||||
"""
|
||||
diff = abs(song["remaining"] - (time.time() - song["started"]))
|
||||
|
||||
skipped = diff >= self.time_threshold
|
||||
|
||||
elapsed = song["elapsed_at_start"] + (time.time() - song["started"])
|
||||
skipped = elapsed / song["duration"] < self.played_ratio_threshold
|
||||
if skipped:
|
||||
self.handle_skipped(song)
|
||||
else:
|
||||
|
|
@ -255,13 +257,10 @@ class MPDStats:
|
|||
|
||||
def on_play(self, status):
|
||||
path, songid = self.mpd.currentsong()
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
played, duration = map(int, status["time"].split(":", 1))
|
||||
remaining = duration - played
|
||||
|
||||
if self.now_playing:
|
||||
if self.now_playing["path"] != path:
|
||||
self.handle_song_change(self.now_playing)
|
||||
|
|
@ -272,7 +271,7 @@ class MPDStats:
|
|||
# after natural song start.
|
||||
diff = abs(time.time() - self.now_playing["started"])
|
||||
|
||||
if diff <= self.time_threshold:
|
||||
if diff <= DUPLICATE_PLAY_THRESHOLD:
|
||||
return
|
||||
|
||||
if self.now_playing["path"] == path and played == 0:
|
||||
|
|
@ -287,7 +286,8 @@ class MPDStats:
|
|||
|
||||
self.now_playing = {
|
||||
"started": time.time(),
|
||||
"remaining": remaining,
|
||||
"elapsed_at_start": played,
|
||||
"duration": duration,
|
||||
"path": path,
|
||||
"id": songid,
|
||||
"beets_item": self.get_item(path),
|
||||
|
|
@ -321,7 +321,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
item_types = {
|
||||
"play_count": types.INTEGER,
|
||||
"skip_count": types.INTEGER,
|
||||
"last_played": library.DateType(),
|
||||
"last_played": types.DATE,
|
||||
"rating": types.FLOAT,
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +336,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
"host": os.environ.get("MPD_HOST", "localhost"),
|
||||
"port": int(os.environ.get("MPD_PORT", 6600)),
|
||||
"password": "",
|
||||
"played_ratio_threshold": 0.85,
|
||||
}
|
||||
)
|
||||
mpd_config["password"].redact = True
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import traceback
|
|||
from collections import Counter
|
||||
from functools import cached_property
|
||||
from itertools import product
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Sequence
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import musicbrainzngs
|
||||
|
|
@ -28,11 +28,10 @@ import musicbrainzngs
|
|||
import beets
|
||||
import beets.autotag.hooks
|
||||
from beets import config, plugins, util
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.metadata_plugins import MetadataSourcePlugin
|
||||
from beets.util.id_extractors import extract_release_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
from typing import Literal
|
||||
|
||||
from beets.library import Item
|
||||
|
|
@ -362,9 +361,7 @@ def _merge_pseudo_and_actual_album(
|
|||
return merged
|
||||
|
||||
|
||||
class MusicBrainzPlugin(BeetsPlugin):
|
||||
data_source = "Musicbrainz"
|
||||
|
||||
class MusicBrainzPlugin(MetadataSourcePlugin):
|
||||
def __init__(self):
|
||||
"""Set up the python-musicbrainz-ngs module according to settings
|
||||
from the beets configuration. This should be called at startup.
|
||||
|
|
@ -421,7 +418,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
medium=medium,
|
||||
medium_index=medium_index,
|
||||
medium_total=medium_total,
|
||||
data_source="MusicBrainz",
|
||||
data_source=self.data_source,
|
||||
data_url=track_url(recording["id"]),
|
||||
)
|
||||
|
||||
|
|
@ -632,7 +629,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
artists_sort=artists_sort_names,
|
||||
artist_credit=artist_credit_name,
|
||||
artists_credit=artists_credit_names,
|
||||
data_source="MusicBrainz",
|
||||
data_source=self.data_source,
|
||||
data_url=album_url(release["id"]),
|
||||
barcode=release.get("barcode"),
|
||||
)
|
||||
|
|
@ -767,7 +764,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
return mb_field_by_tag
|
||||
|
||||
def get_album_criteria(
|
||||
self, items: list[Item], artist: str, album: str, va_likely: bool
|
||||
self, items: Sequence[Item], artist: str, album: str, va_likely: bool
|
||||
) -> dict[str, str]:
|
||||
criteria = {
|
||||
"release": album,
|
||||
|
|
@ -813,12 +810,11 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
|
||||
def candidates(
|
||||
self,
|
||||
items: list[Item],
|
||||
items: Sequence[Item],
|
||||
artist: str,
|
||||
album: str,
|
||||
va_likely: bool,
|
||||
extra_tags: dict[str, Any] | None = None,
|
||||
) -> Iterator[beets.autotag.hooks.AlbumInfo]:
|
||||
) -> Iterable[beets.autotag.hooks.AlbumInfo]:
|
||||
criteria = self.get_album_criteria(items, artist, album, va_likely)
|
||||
release_ids = (r["id"] for r in self._search_api("release", criteria))
|
||||
|
||||
|
|
@ -826,7 +822,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
|
||||
def item_candidates(
|
||||
self, item: Item, artist: str, title: str
|
||||
) -> Iterator[beets.autotag.hooks.TrackInfo]:
|
||||
) -> Iterable[beets.autotag.hooks.TrackInfo]:
|
||||
criteria = {"artist": artist, "recording": title, "alias": title}
|
||||
|
||||
yield from filter(
|
||||
|
|
@ -841,7 +837,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
MusicBrainzAPIError.
|
||||
"""
|
||||
self._log.debug("Requesting MusicBrainz release {}", album_id)
|
||||
if not (albumid := extract_release_id("musicbrainz", album_id)):
|
||||
if not (albumid := self._extract_id(album_id)):
|
||||
self._log.debug("Invalid MBID ({0}).", album_id)
|
||||
return None
|
||||
|
||||
|
|
@ -878,7 +874,7 @@ class MusicBrainzPlugin(BeetsPlugin):
|
|||
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
|
||||
or None if no track is found. May raise a MusicBrainzAPIError.
|
||||
"""
|
||||
if not (trackid := extract_release_id("musicbrainz", track_id)):
|
||||
if not (trackid := self._extract_id(track_id)):
|
||||
self._log.debug("Invalid MBID ({0}).", track_id)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class ParentWorkPlugin(BeetsPlugin):
|
|||
force_parent = self.config["force"].get(bool)
|
||||
write = ui.should_write()
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
for item in lib.items(args):
|
||||
changed = self.find_work(item, force_parent, verbose=True)
|
||||
if changed:
|
||||
item.store()
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class PlayPlugin(BeetsPlugin):
|
|||
# Perform search by album and add folders rather than tracks to
|
||||
# playlist.
|
||||
if opts.album:
|
||||
selection = lib.albums(ui.decargs(args))
|
||||
selection = lib.albums(args)
|
||||
paths = []
|
||||
|
||||
sort = lib.get_default_album_sort()
|
||||
|
|
@ -120,7 +120,7 @@ class PlayPlugin(BeetsPlugin):
|
|||
|
||||
# Perform item query and add tracks to playlist.
|
||||
else:
|
||||
selection = lib.items(ui.decargs(args))
|
||||
selection = lib.items(args)
|
||||
paths = [item.path for item in selection]
|
||||
item_type = "track"
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,20 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import beets
|
||||
from beets.dbcore.query import InQuery
|
||||
from beets.library import BLOB_TYPE
|
||||
from beets.dbcore.query import BLOB_TYPE, InQuery
|
||||
from beets.util import path_as_posix
|
||||
|
||||
|
||||
def is_m3u_file(path: str) -> bool:
|
||||
return Path(path).suffix.lower() in {".m3u", ".m3u8"}
|
||||
|
||||
|
||||
class PlaylistQuery(InQuery[bytes]):
|
||||
"""Matches files listed by a playlist file."""
|
||||
|
||||
|
|
@ -46,7 +49,7 @@ class PlaylistQuery(InQuery[bytes]):
|
|||
|
||||
paths = []
|
||||
for playlist_path in playlist_paths:
|
||||
if not fnmatch.fnmatch(playlist_path, "*.[mM]3[uU]"):
|
||||
if not is_m3u_file(playlist_path):
|
||||
# This is not am M3U playlist, skip this candidate
|
||||
continue
|
||||
|
||||
|
|
@ -149,7 +152,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
|
|||
return
|
||||
|
||||
for filename in dir_contents:
|
||||
if fnmatch.fnmatch(filename, "*.[mM]3[uU]"):
|
||||
if is_m3u_file(filename):
|
||||
yield os.path.join(self.playlist_dir, filename)
|
||||
|
||||
def update_playlist(self, filename, base_dir):
|
||||
|
|
|
|||
|
|
@ -16,17 +16,16 @@
|
|||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.random import random_objs
|
||||
from beets.ui import Subcommand, decargs, print_
|
||||
from beets.ui import Subcommand, print_
|
||||
|
||||
|
||||
def random_func(lib, opts, args):
|
||||
"""Select some random items or albums and print the results."""
|
||||
# Fetch all the objects matching the query into a list.
|
||||
query = decargs(args)
|
||||
if opts.album:
|
||||
objs = list(lib.albums(query))
|
||||
objs = list(lib.albums(args))
|
||||
else:
|
||||
objs = list(lib.items(query))
|
||||
objs = list(lib.items(args))
|
||||
|
||||
# Print a random subset.
|
||||
objs = random_objs(
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class FatalGstreamerPluginReplayGainError(FatalReplayGainError):
|
|||
loading the required plugins."""
|
||||
|
||||
|
||||
def call(args: list[Any], log: Logger, **kwargs: Any):
|
||||
def call(args: list[str], log: Logger, **kwargs: Any):
|
||||
"""Execute the command and return its output or raise a
|
||||
ReplayGainError on failure.
|
||||
"""
|
||||
|
|
@ -73,11 +73,6 @@ def call(args: list[Any], log: Logger, **kwargs: Any):
|
|||
raise ReplayGainError(
|
||||
"{} exited with status {}".format(args[0], e.returncode)
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
# Due to a bug in Python 2's subprocess on Windows, Unicode
|
||||
# filenames can fail to encode on that platform. See:
|
||||
# https://github.com/google-code-export/beets/issues/499
|
||||
raise ReplayGainError("argument encoding failed")
|
||||
|
||||
|
||||
def db_to_lufs(db: float) -> float:
|
||||
|
|
@ -403,20 +398,18 @@ class FfmpegBackend(Backend):
|
|||
|
||||
def _construct_cmd(
|
||||
self, item: Item, peak_method: PeakMethod | None
|
||||
) -> list[str | bytes]:
|
||||
) -> list[str]:
|
||||
"""Construct the shell command to analyse items."""
|
||||
return [
|
||||
self._ffmpeg_path,
|
||||
"-nostats",
|
||||
"-hide_banner",
|
||||
"-i",
|
||||
item.path,
|
||||
str(item.filepath),
|
||||
"-map",
|
||||
"a:0",
|
||||
"-filter",
|
||||
"ebur128=peak={}".format(
|
||||
"none" if peak_method is None else peak_method.name
|
||||
),
|
||||
f"ebur128=peak={'none' if peak_method is None else peak_method.name}",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
|
|
@ -660,7 +653,7 @@ class CommandBackend(Backend):
|
|||
# tag-writing; this turns the mp3gain/aacgain tool into a gain
|
||||
# calculator rather than a tag manipulator because we take care
|
||||
# of changing tags ourselves.
|
||||
cmd: list[bytes | str] = [self.command, "-o", "-s", "s"]
|
||||
cmd: list[str] = [self.command, "-o", "-s", "s"]
|
||||
if self.noclip:
|
||||
# Adjust to avoid clipping.
|
||||
cmd = cmd + ["-k"]
|
||||
|
|
@ -1039,7 +1032,7 @@ class AudioToolsBackend(Backend):
|
|||
os.fsdecode(syspath(item.path))
|
||||
)
|
||||
except OSError:
|
||||
raise ReplayGainError(f"File {item.path} was not found")
|
||||
raise ReplayGainError(f"File {item.filepath} was not found")
|
||||
except self._mod_audiotools.UnsupportedFile:
|
||||
raise ReplayGainError(f"Unsupported file type {item.format}")
|
||||
|
||||
|
|
@ -1168,7 +1161,9 @@ class ExceptionWatcher(Thread):
|
|||
Once an exception occurs, raise it and execute a callback.
|
||||
"""
|
||||
|
||||
def __init__(self, queue: queue.Queue, callback: Callable[[], None]):
|
||||
def __init__(
|
||||
self, queue: queue.Queue[Exception], callback: Callable[[], None]
|
||||
):
|
||||
self._queue = queue
|
||||
self._callback = callback
|
||||
self._stopevent = Event()
|
||||
|
|
@ -1204,7 +1199,9 @@ BACKENDS: dict[str, type[Backend]] = {b.NAME: b for b in BACKEND_CLASSES}
|
|||
class ReplayGainPlugin(BeetsPlugin):
|
||||
"""Provides ReplayGain analysis."""
|
||||
|
||||
def __init__(self):
|
||||
pool: ThreadPool | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
# default backend is 'command' for backward-compatibility.
|
||||
|
|
@ -1268,9 +1265,6 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
except (ReplayGainError, FatalReplayGainError) as e:
|
||||
raise ui.UserError(f"replaygain initialization failed: {e}")
|
||||
|
||||
# Start threadpool lazily.
|
||||
self.pool = None
|
||||
|
||||
def should_use_r128(self, item: Item) -> bool:
|
||||
"""Checks the plugin setting to decide whether the calculation
|
||||
should be done using the EBU R128 standard and use R128_ tags instead.
|
||||
|
|
@ -1427,7 +1421,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
"""Open a `ThreadPool` instance in `self.pool`"""
|
||||
if self.pool is None and self.backend_instance.do_parallel:
|
||||
self.pool = ThreadPool(threads)
|
||||
self.exc_queue: queue.Queue = queue.Queue()
|
||||
self.exc_queue: queue.Queue[Exception] = queue.Queue()
|
||||
|
||||
signal.signal(signal.SIGINT, self._interrupt)
|
||||
|
||||
|
|
@ -1530,7 +1524,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
self.open_pool(threads)
|
||||
|
||||
if opts.album:
|
||||
albums = lib.albums(ui.decargs(args))
|
||||
albums = lib.albums(args)
|
||||
self._log.info(
|
||||
"Analyzing {} albums ~ {} backend...".format(
|
||||
len(albums), self.backend_name
|
||||
|
|
@ -1539,7 +1533,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
for album in albums:
|
||||
self.handle_album(album, write, force)
|
||||
else:
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self._log.info(
|
||||
"Analyzing {} tracks ~ {} backend...".format(
|
||||
len(items), self.backend_name
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class ScrubPlugin(BeetsPlugin):
|
|||
def commands(self):
|
||||
def scrub_func(lib, opts, args):
|
||||
# Walk through matching files and remove tags.
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
for item in lib.items(args):
|
||||
self._log.info(
|
||||
"scrubbing: {0}", util.displayable_path(item.path)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
|
|||
def update_cmd(self, lib, opts, args):
|
||||
self.build_queries()
|
||||
if args:
|
||||
args = set(ui.decargs(args))
|
||||
args = set(args)
|
||||
for a in list(args):
|
||||
if not a.endswith(".m3u"):
|
||||
args.add(f"{a}.m3u")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import json
|
|||
import re
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import TYPE_CHECKING, Any, Literal, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
|
||||
|
||||
import confuse
|
||||
import requests
|
||||
|
|
@ -34,22 +34,55 @@ import unidecode
|
|||
from beets import ui
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.dbcore import types
|
||||
from beets.library import DateType, Library
|
||||
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, Response
|
||||
from beets.library import Library
|
||||
from beets.metadata_plugins import (
|
||||
IDResponse,
|
||||
SearchApiMetadataSourcePlugin,
|
||||
SearchFilter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beets.library import Library
|
||||
from beetsplug._typing import JSONDict
|
||||
|
||||
DEFAULT_WAITING_TIME = 5
|
||||
|
||||
|
||||
class SpotifyAPIError(Exception):
|
||||
class SearchResponseAlbums(IDResponse):
|
||||
"""A response returned by the Spotify API.
|
||||
|
||||
We only use items and disregard the pagination information.
|
||||
i.e. res["albums"]["items"][0].
|
||||
|
||||
There are more fields in the response, but we only type
|
||||
the ones we currently use.
|
||||
|
||||
see https://developer.spotify.com/documentation/web-api/reference/search
|
||||
"""
|
||||
|
||||
album_type: str
|
||||
available_markets: Sequence[str]
|
||||
name: str
|
||||
|
||||
|
||||
class SearchResponseTracks(IDResponse):
|
||||
"""A track response returned by the Spotify API."""
|
||||
|
||||
album: SearchResponseAlbums
|
||||
available_markets: Sequence[str]
|
||||
popularity: int
|
||||
name: str
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
||||
data_source = "Spotify"
|
||||
|
||||
class SpotifyPlugin(
|
||||
SearchApiMetadataSourcePlugin[
|
||||
Union[SearchResponseAlbums, SearchResponseTracks]
|
||||
]
|
||||
):
|
||||
item_types = {
|
||||
"spotify_track_popularity": types.INTEGER,
|
||||
"spotify_acousticness": types.FLOAT,
|
||||
|
|
@ -64,7 +97,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
"spotify_tempo": types.FLOAT,
|
||||
"spotify_time_signature": types.INTEGER,
|
||||
"spotify_valence": types.FLOAT,
|
||||
"spotify_updated": DateType(),
|
||||
"spotify_updated": types.DATE,
|
||||
}
|
||||
|
||||
# Base URLs for the Spotify API
|
||||
|
|
@ -106,6 +139,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
"client_id": "4e414367a1d14c75a5c5129a627fcab8",
|
||||
"client_secret": "f82bdc09b2254f1a8286815d02fd46dc",
|
||||
"tokenfile": "spotify_token.json",
|
||||
"search_query_ascii": False,
|
||||
}
|
||||
)
|
||||
self.config["client_id"].redact = True
|
||||
|
|
@ -128,7 +162,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
"""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):
|
||||
def _authenticate(self) -> None:
|
||||
"""Request an access token via the Client Credentials Flow:
|
||||
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
|
||||
"""
|
||||
|
|
@ -179,7 +213,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
"""
|
||||
|
||||
if retry_count > max_retries:
|
||||
raise SpotifyAPIError("Maximum retries reached.")
|
||||
raise APIError("Maximum retries reached.")
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
|
|
@ -193,14 +227,14 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
return response.json()
|
||||
except requests.exceptions.ReadTimeout:
|
||||
self._log.error("ReadTimeout.")
|
||||
raise SpotifyAPIError("Request timed out.")
|
||||
raise APIError("Request timed out.")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
self._log.error(f"Network error: {e}")
|
||||
raise SpotifyAPIError("Network error.")
|
||||
raise APIError("Network error.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
if e.response is None:
|
||||
self._log.error(f"Request failed: {e}")
|
||||
raise SpotifyAPIError("Request failed.")
|
||||
raise APIError("Request failed.")
|
||||
if e.response.status_code == 401:
|
||||
self._log.debug(
|
||||
f"{self.data_source} access token has expired. "
|
||||
|
|
@ -214,7 +248,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
retry_count=retry_count + 1,
|
||||
)
|
||||
elif e.response.status_code == 404:
|
||||
raise SpotifyAPIError(
|
||||
raise APIError(
|
||||
f"API Error: {e.response.status_code}\n"
|
||||
f"URL: {url}\nparams: {params}"
|
||||
)
|
||||
|
|
@ -234,18 +268,18 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
)
|
||||
elif e.response.status_code == 503:
|
||||
self._log.error("Service Unavailable.")
|
||||
raise SpotifyAPIError("Service Unavailable.")
|
||||
raise APIError("Service Unavailable.")
|
||||
elif e.response.status_code == 502:
|
||||
self._log.error("Bad Gateway.")
|
||||
raise SpotifyAPIError("Bad Gateway.")
|
||||
raise APIError("Bad Gateway.")
|
||||
elif e.response is not None:
|
||||
raise SpotifyAPIError(
|
||||
raise APIError(
|
||||
f"{self.data_source} API error:\n{e.response.text}\n"
|
||||
f"URL:\n{url}\nparams:\n{params}"
|
||||
)
|
||||
else:
|
||||
self._log.error(f"Request failed. Error: {e}")
|
||||
raise SpotifyAPIError("Request failed.")
|
||||
raise APIError("Request failed.")
|
||||
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""Fetch an album by its Spotify ID or URL and return an
|
||||
|
|
@ -256,7 +290,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
:return: AlbumInfo object for album
|
||||
:rtype: beets.autotag.hooks.AlbumInfo or None
|
||||
"""
|
||||
if not (spotify_id := self._get_id(album_id)):
|
||||
if not (spotify_id := self._extract_id(album_id)):
|
||||
return None
|
||||
|
||||
album_data = self._handle_response("get", self.album_url + spotify_id)
|
||||
|
|
@ -359,7 +393,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
Returns a TrackInfo object or None if the track is not found.
|
||||
"""
|
||||
|
||||
if not (spotify_id := self._get_id(track_id)):
|
||||
if not (spotify_id := self._extract_id(track_id)):
|
||||
self._log.debug("Invalid Spotify ID: {}", track_id)
|
||||
return None
|
||||
|
||||
|
|
@ -388,9 +422,8 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
track.medium_total = medium_total
|
||||
return track
|
||||
|
||||
@staticmethod
|
||||
def _construct_search_query(
|
||||
filters: dict[str, str], keywords: str = ""
|
||||
self, filters: SearchFilter, keywords: str = ""
|
||||
) -> str:
|
||||
"""Construct a query string with the specified filters and keywords to
|
||||
be provided to the Spotify Search API
|
||||
|
|
@ -400,21 +433,26 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
:param keywords: (Optional) Query keywords to use.
|
||||
:return: Query string to be provided to the Search API.
|
||||
"""
|
||||
|
||||
query_components = [
|
||||
keywords,
|
||||
" ".join(":".join((k, v)) for k, v in filters.items()),
|
||||
" ".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)
|
||||
|
||||
if self.config["search_query_ascii"].get():
|
||||
query = unidecode.unidecode(query)
|
||||
|
||||
return query
|
||||
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal["album", "track"],
|
||||
filters: dict[str, str],
|
||||
filters: SearchFilter,
|
||||
keywords: str = "",
|
||||
) -> Sequence[Response]:
|
||||
) -> Sequence[SearchResponseAlbums | SearchResponseTracks]:
|
||||
"""Query the Spotify Search API for the specified ``keywords``,
|
||||
applying the provided ``filters``.
|
||||
|
||||
|
|
@ -424,6 +462,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
:param keywords: (Optional) Query keywords to use.
|
||||
"""
|
||||
query = self._construct_search_query(keywords=keywords, filters=filters)
|
||||
|
||||
self._log.debug(f"Searching {self.data_source} for '{query}'")
|
||||
try:
|
||||
response = self._handle_response(
|
||||
|
|
@ -431,7 +470,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
self.search_url,
|
||||
params={"q": query, "type": query_type},
|
||||
)
|
||||
except SpotifyAPIError as e:
|
||||
except APIError as e:
|
||||
self._log.debug("Spotify API error: {}", e)
|
||||
return ()
|
||||
response_data = response.get(query_type + "s", {}).get("items", [])
|
||||
|
|
@ -448,7 +487,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
def queries(lib, opts, args):
|
||||
success = self._parse_opts(opts)
|
||||
if success:
|
||||
results = self._match_library_tracks(lib, ui.decargs(args))
|
||||
results = self._match_library_tracks(lib, args)
|
||||
self._output_match_results(results)
|
||||
|
||||
spotify_cmd = ui.Subcommand(
|
||||
|
|
@ -486,7 +525,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
items = lib.items(ui.decargs(args))
|
||||
items = lib.items(args)
|
||||
self._fetch_info(items, ui.should_write(), opts.force_refetch)
|
||||
|
||||
sync_cmd.func = func
|
||||
|
|
@ -552,7 +591,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
keywords = item[self.config["track_field"].get()]
|
||||
|
||||
# Query the Web API for each track, look for the items' JSON data
|
||||
query_filters = {"artist": artist, "album": album}
|
||||
query_filters: SearchFilter = {"artist": artist, "album": album}
|
||||
response_data_tracks = self._search_api(
|
||||
query_type="track", keywords=keywords, filters=query_filters
|
||||
)
|
||||
|
|
@ -560,11 +599,12 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
query = self._construct_search_query(
|
||||
keywords=keywords, filters=query_filters
|
||||
)
|
||||
|
||||
failures.append(query)
|
||||
continue
|
||||
|
||||
# Apply market filter if requested
|
||||
region_filter = self.config["region_filter"].get()
|
||||
region_filter: str = self.config["region_filter"].get()
|
||||
if region_filter:
|
||||
response_data_tracks = [
|
||||
track_data
|
||||
|
|
@ -589,7 +629,11 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
len(response_data_tracks),
|
||||
)
|
||||
chosen_result = max(
|
||||
response_data_tracks, key=lambda x: x["popularity"]
|
||||
response_data_tracks,
|
||||
key=lambda x: x[
|
||||
# We are sure this is a track response!
|
||||
"popularity" # type: ignore[typeddict-item]
|
||||
],
|
||||
)
|
||||
results.append(chosen_result)
|
||||
|
||||
|
|
@ -685,16 +729,18 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
def track_info(self, track_id: str):
|
||||
"""Fetch a track's popularity and external IDs using its Spotify ID."""
|
||||
track_data = self._handle_response("get", self.track_url + track_id)
|
||||
external_ids = track_data.get("external_ids", {})
|
||||
popularity = track_data.get("popularity")
|
||||
self._log.debug(
|
||||
"track_popularity: {} and track_isrc: {}",
|
||||
track_data.get("popularity"),
|
||||
track_data.get("external_ids").get("isrc"),
|
||||
popularity,
|
||||
external_ids.get("isrc"),
|
||||
)
|
||||
return (
|
||||
track_data.get("popularity"),
|
||||
track_data.get("external_ids").get("isrc"),
|
||||
track_data.get("external_ids").get("ean"),
|
||||
track_data.get("external_ids").get("upc"),
|
||||
popularity,
|
||||
external_ids.get("isrc"),
|
||||
external_ids.get("ean"),
|
||||
external_ids.get("upc"),
|
||||
)
|
||||
|
||||
def track_audio_features(self, track_id: str):
|
||||
|
|
@ -703,6 +749,6 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
return self._handle_response(
|
||||
"get", self.audio_features_url + track_id
|
||||
)
|
||||
except SpotifyAPIError as e:
|
||||
except APIError as e:
|
||||
self._log.debug("Spotify API error: {}", e)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from pathlib import PurePosixPath
|
|||
from xdg import BaseDirectory
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs
|
||||
from beets.ui import Subcommand
|
||||
from beets.util import bytestring_path, displayable_path, syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ class ThumbnailsPlugin(BeetsPlugin):
|
|||
def process_query(self, lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
if self._check_local_ok():
|
||||
for album in lib.albums(decargs(args)):
|
||||
for album in lib.albums(args):
|
||||
self.process_album(album)
|
||||
|
||||
def _check_local_ok(self):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
from confuse import ConfigValueError
|
||||
|
||||
from beets import library
|
||||
from beets.dbcore import types
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
|
|
@ -42,7 +41,7 @@ class TypesPlugin(BeetsPlugin):
|
|||
elif value.get() == "bool":
|
||||
mytypes[key] = types.BOOLEAN
|
||||
elif value.get() == "date":
|
||||
mytypes[key] = library.DateType()
|
||||
mytypes[key] = types.DATE
|
||||
else:
|
||||
raise ConfigValueError(
|
||||
"unknown type '{}' for the '{}' field".format(value, key)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from werkzeug.routing import BaseConverter, PathConverter
|
|||
|
||||
import beets.library
|
||||
from beets import ui, util
|
||||
from beets.dbcore.query import PathQuery
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
# Utilities.
|
||||
|
|
@ -307,18 +308,8 @@ def all_items():
|
|||
def item_file(item_id):
|
||||
item = g.lib.get_item(item_id)
|
||||
|
||||
# On Windows under Python 2, Flask wants a Unicode path. On Python 3, it
|
||||
# *always* wants a Unicode path.
|
||||
if os.name == "nt":
|
||||
item_path = util.syspath(item.path)
|
||||
else:
|
||||
item_path = os.fsdecode(item.path)
|
||||
|
||||
item_path = util.syspath(item.path)
|
||||
base_filename = os.path.basename(item_path)
|
||||
if isinstance(base_filename, bytes):
|
||||
unicode_base_filename = util.displayable_path(base_filename)
|
||||
else:
|
||||
unicode_base_filename = base_filename
|
||||
|
||||
try:
|
||||
# Imitate http.server behaviour
|
||||
|
|
@ -326,7 +317,7 @@ def item_file(item_id):
|
|||
except UnicodeError:
|
||||
safe_filename = unidecode(base_filename)
|
||||
else:
|
||||
safe_filename = unicode_base_filename
|
||||
safe_filename = base_filename
|
||||
|
||||
response = flask.send_file(
|
||||
item_path, as_attachment=True, download_name=safe_filename
|
||||
|
|
@ -342,7 +333,7 @@ def item_query(queries):
|
|||
|
||||
@app.route("/item/path/<everything:path>")
|
||||
def item_at_path(path):
|
||||
query = beets.library.PathQuery("path", path.encode("utf-8"))
|
||||
query = PathQuery("path", path.encode("utf-8"))
|
||||
item = g.lib.items(query).get()
|
||||
if item:
|
||||
return flask.jsonify(_rep(item))
|
||||
|
|
@ -469,7 +460,7 @@ class WebPlugin(BeetsPlugin):
|
|||
)
|
||||
|
||||
def func(lib, opts, args):
|
||||
args = ui.decargs(args)
|
||||
args = args
|
||||
if args:
|
||||
self.config["host"] = args.pop(0)
|
||||
if args:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from mediafile import MediaFile
|
|||
|
||||
from beets.importer import Action
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs, input_yn
|
||||
from beets.ui import Subcommand, input_yn
|
||||
|
||||
__author__ = "baobab@heresiarch.info"
|
||||
|
||||
|
|
@ -75,11 +75,11 @@ class ZeroPlugin(BeetsPlugin):
|
|||
zero_command = Subcommand("zero", help="set fields to null")
|
||||
|
||||
def zero_fields(lib, opts, args):
|
||||
if not decargs(args) and not input_yn(
|
||||
if not args and not input_yn(
|
||||
"Remove fields for all items? (Y/n)", True
|
||||
):
|
||||
return
|
||||
for item in lib.items(decargs(args)):
|
||||
for item in lib.items(args):
|
||||
self.process_item(item)
|
||||
|
||||
zero_command.func = zero_fields
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Don't post a comment on pull requests.
|
||||
comment: off
|
||||
comment:
|
||||
layout: "condensed_header, condensed_files"
|
||||
require_changes: true
|
||||
|
||||
# Sets non-blocking status checks
|
||||
# https://docs.codecov.com/docs/commit-status#informational
|
||||
|
|
@ -11,7 +12,7 @@ coverage:
|
|||
patch:
|
||||
default:
|
||||
informational: true
|
||||
changes: no
|
||||
changes: false
|
||||
|
||||
github_checks:
|
||||
annotations: false
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ New features:
|
|||
singletons by their Discogs ID.
|
||||
:bug:`4661`
|
||||
* :doc:`plugins/replace`: Add new plugin.
|
||||
* :doc:`plugins/duplicates`: Add ``--remove`` option, allowing to remove from
|
||||
the library without deleting media files.
|
||||
:bug:`5832`
|
||||
* :doc:`plugins/playlist`: Support files with the `.m3u8` extension.
|
||||
:bug:`5829`
|
||||
* :doc:`plugins/mbcollection`: When getting the user collections, only consider
|
||||
collections of releases, and ignore collections of other entity types.
|
||||
* :doc:`plugins/mpdstats`: Add new configuration option,
|
||||
``played_ratio_threshold``, to allow configuring the percentage the song must
|
||||
be played for it to be counted as played instead of skipped.
|
||||
|
||||
Bug fixes:
|
||||
|
||||
|
|
@ -42,7 +52,12 @@ Bug fixes:
|
|||
* :doc:`reference/cli`: Fix 'from_scratch' option for singleton imports: delete
|
||||
all (old) metadata when new metadata is applied.
|
||||
:bug:`3706`
|
||||
|
||||
* :doc:`/plugins/spotify`: Fix the issue with that every query to spotify was
|
||||
ascii encoded. This resulted in bad matches for queries that contained special
|
||||
e.g. non latin characters as 盗作. If you want to keep the legacy behavior
|
||||
set the config option ``spotify.search_query_ascii: yes``.
|
||||
:bug:`5699`
|
||||
|
||||
For packagers:
|
||||
|
||||
* Optional ``extra_tags`` parameter has been removed from
|
||||
|
|
@ -54,11 +69,26 @@ For plugin developers:
|
|||
* The `fetchart` plugins has seen a few changes to function signatures and
|
||||
source registration in the process of introducing typings to the code.
|
||||
Custom art sources might need to be adapted.
|
||||
|
||||
* We split the responsibilities of plugins into two base classes
|
||||
#. :class:`beets.plugins.BeetsPlugin`
|
||||
is the base class for all plugins, any plugin needs to inherit from this class.
|
||||
#. :class:`beets.metadata_plugin.MetadataSourcePlugin`
|
||||
allows plugins to act like metadata sources. E.g. used by the MusicBrainz plugin. All plugins
|
||||
in the beets repo are opted into this class where applicable. If you are maintaining a plugin
|
||||
that acts like a metadata source, i.e. you expose any of ``track_for_id``,
|
||||
``album_for_id``, ``candidates``, ``item_candidates``, ``album_distance``, ``track_distance`` methods,
|
||||
please update your plugin to inherit from the new baseclass, as otherwise your plugin will
|
||||
stop working with the next major release.
|
||||
|
||||
Other changes:
|
||||
|
||||
* Refactor: Split responsibilities of Plugins into MetaDataPlugins and general Plugins.
|
||||
* Documentation structure for auto generated API references changed slightly.
|
||||
Autogenerated API references are now located in the `docs/api` subdirectory.
|
||||
* :doc:`/plugins/substitute`: Fix rST formatting for example cases so that each
|
||||
case is shown on separate lines.
|
||||
* Refactored library.py file by splitting it into multiple modules within the
|
||||
beets/library directory.
|
||||
|
||||
2.3.1 (May 14, 2025)
|
||||
--------------------
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ duplicates themselves via command-line switches ::
|
|||
-o DEST, --copy=DEST copy items to dest
|
||||
-p, --path print paths for matched items or albums
|
||||
-t TAG, --tag=TAG tag matched items with 'k=v' attribute
|
||||
-r, --remove remove items from library
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
@ -57,7 +58,7 @@ file. The available options mirror the command-line options:
|
|||
``$albumartist - $album - $title: $count`` (for tracks) or ``$albumartist -
|
||||
$album: $count`` (for albums).
|
||||
Default: ``no``.
|
||||
- **delete**: Removes matched items from the library and from the disk.
|
||||
- **delete**: Remove matched items from the library and from the disk.
|
||||
Default: ``no``
|
||||
- **format**: A specific format with which to print every track
|
||||
or album. This uses the same template syntax as beets'
|
||||
|
|
@ -92,6 +93,8 @@ file. The available options mirror the command-line options:
|
|||
set. If you would like to consider the lower bitrates as duplicates,
|
||||
for example, set ``tiebreak: items: [bitrate]``.
|
||||
Default: ``{}``.
|
||||
- **remove**: Remove matched items from the library, but not from the disk.
|
||||
Default: ``no``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ configuration file. The available options are:
|
|||
Default: ``yes``.
|
||||
- **rating_mix**: Tune the way rating is calculated (see below).
|
||||
Default: 0.75.
|
||||
- **played_ratio_threshold**: If a song was played for less than this percentage
|
||||
of its duration it will be considered a skip.
|
||||
Default: 0.85
|
||||
|
||||
A Word on Ratings
|
||||
-----------------
|
||||
|
|
|
|||
|
|
@ -83,6 +83,13 @@ in config.yaml under the ``spotify:`` section:
|
|||
track/album/artist fields before sending them to Spotify. Can be useful for
|
||||
changing certain abbreviations, like ft. -> feat. See the examples below.
|
||||
Default: None.
|
||||
- **search_query_ascii**: If set to ``yes``, the search query will be converted to
|
||||
ASCII before being sent to Spotify. Converting searches to ASCII can
|
||||
enhance search results in some cases, but in general, it is not recommended.
|
||||
For instance `artist:deadmau5 album:4×4` will be converted to
|
||||
`artist:deadmau5 album:4x4` (notice `×!=x`).
|
||||
Default: ``no``.
|
||||
|
||||
|
||||
Here's an example::
|
||||
|
||||
|
|
@ -92,6 +99,7 @@ Here's an example::
|
|||
region_filter: US
|
||||
show_failures: on
|
||||
tiebreak: first
|
||||
search_query_ascii: no
|
||||
|
||||
regex: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ group in the output, discarding the rest of the string.
|
|||
|
||||
This would handle all the below cases in a single rule:
|
||||
|
||||
Bob Dylan and The Band -> Bob Dylan
|
||||
Neil Young & Crazy Horse -> Neil Young
|
||||
James Yorkston, Nina Persson & The Second Hand Orchestra -> James Yorkston
|
||||
| Bob Dylan and The Band -> Bob Dylan
|
||||
| Neil Young & Crazy Horse -> Neil Young
|
||||
| James Yorkston, Nina Persson & The Second Hand Orchestra -> James Yorkston
|
||||
|
||||
|
||||
To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example:
|
||||
|
|
|
|||
1147
poetry.lock
generated
1147
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -124,7 +124,7 @@ aura = ["flask", "flask-cors", "Pillow"]
|
|||
autobpm = ["librosa", "resampy"]
|
||||
# badfiles # mp3val and flac
|
||||
beatport = ["requests-oauthlib"]
|
||||
bpd = ["PyGObject"] # python-gi and GStreamer 1.0+
|
||||
bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0
|
||||
chroma = ["pyacoustid"] # chromaprint or fpcalc
|
||||
# convert # ffmpeg
|
||||
docs = ["pydata-sphinx-theme", "sphinx"]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ markers =
|
|||
data_file = .reports/coverage/data
|
||||
branch = true
|
||||
relative_files = true
|
||||
omit =
|
||||
omit =
|
||||
beets/test/*
|
||||
beetsplug/_typing.py
|
||||
|
||||
|
|
@ -34,7 +34,6 @@ exclude_also =
|
|||
show_contexts = true
|
||||
|
||||
[mypy]
|
||||
files = beets,beetsplug,test,extra,docs
|
||||
allow_any_generics = false
|
||||
# FIXME: Would be better to actually type the libraries (if under our control),
|
||||
# or write our own stubs. For now, silence errors
|
||||
|
|
@ -46,6 +45,8 @@ explicit_package_bases = true
|
|||
# config for all files.
|
||||
[[mypy-beets.plugins]]
|
||||
disallow_untyped_decorators = true
|
||||
disallow_any_generics = true
|
||||
check_untyped_defs = true
|
||||
allow_redefinition = true
|
||||
|
||||
[[mypy-beets.metadata_plugins]]
|
||||
disallow_untyped_decorators = true
|
||||
check_untyped_defs = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import inspect
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from beets.dbcore.query import Query
|
||||
|
||||
|
||||
def skip_marked_items(items: list[pytest.Item], marker_name: str, reason: str):
|
||||
for item in (i for i in items if i.get_closest_marker(marker_name)):
|
||||
|
|
@ -21,3 +24,20 @@ def pytest_collection_modifyitems(
|
|||
skip_marked_items(
|
||||
items, "on_lyrics_update", "No change in lyrics source code"
|
||||
)
|
||||
|
||||
|
||||
def pytest_make_parametrize_id(config, val, argname):
|
||||
"""Generate readable test identifiers for pytest parametrized tests.
|
||||
|
||||
Provides custom string representations for:
|
||||
- Query classes/instances: use class name
|
||||
- Lambda functions: show abbreviated source
|
||||
- Other values: use standard repr()
|
||||
"""
|
||||
if inspect.isclass(val) and issubclass(val, Query):
|
||||
return val.__name__
|
||||
|
||||
if inspect.isfunction(val) and val.__name__ == "<lambda>":
|
||||
return inspect.getsource(val).split("lambda")[-1][:30]
|
||||
|
||||
return repr(val)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -244,13 +245,13 @@ class FetchImageTest(FetchImageTestCase):
|
|||
self.mock_response(self.URL, "image/png")
|
||||
self.source.fetch_image(self.candidate, self.settings)
|
||||
assert os.path.splitext(self.candidate.path)[1] == b".png"
|
||||
self.assertExists(self.candidate.path)
|
||||
assert Path(os.fsdecode(self.candidate.path)).exists()
|
||||
|
||||
def test_does_not_rely_on_server_content_type(self):
|
||||
self.mock_response(self.URL, "image/jpeg", "image/png")
|
||||
self.source.fetch_image(self.candidate, self.settings)
|
||||
assert os.path.splitext(self.candidate.path)[1] == b".png"
|
||||
self.assertExists(self.candidate.path)
|
||||
assert Path(os.fsdecode(self.candidate.path)).exists()
|
||||
|
||||
|
||||
class FSArtTest(UseThePlugin):
|
||||
|
|
@ -748,8 +749,8 @@ class ArtImporterTest(UseThePlugin):
|
|||
super().setUp()
|
||||
|
||||
# Mock the album art fetcher to always return our test file.
|
||||
self.art_file = os.path.join(self.temp_dir, b"tmpcover.jpg")
|
||||
_common.touch(self.art_file)
|
||||
self.art_file = self.temp_dir_path / "tmpcover.jpg"
|
||||
self.art_file.touch()
|
||||
self.old_afa = self.plugin.art_for_album
|
||||
self.afa_response = fetchart.Candidate(
|
||||
logger,
|
||||
|
|
@ -804,12 +805,10 @@ class ArtImporterTest(UseThePlugin):
|
|||
self.plugin.fetch_art(self.session, self.task)
|
||||
self.plugin.assign_art(self.session, self.task)
|
||||
|
||||
artpath = self.lib.albums()[0].artpath
|
||||
artpath = self.lib.albums()[0].art_filepath
|
||||
if should_exist:
|
||||
assert artpath == os.path.join(
|
||||
os.path.dirname(self.i.path), b"cover.jpg"
|
||||
)
|
||||
self.assertExists(artpath)
|
||||
assert artpath == self.i.filepath.parent / "cover.jpg"
|
||||
assert artpath.exists()
|
||||
else:
|
||||
assert artpath is None
|
||||
return artpath
|
||||
|
|
@ -828,20 +827,20 @@ class ArtImporterTest(UseThePlugin):
|
|||
|
||||
def test_leave_original_file_in_place(self):
|
||||
self._fetch_art(True)
|
||||
self.assertExists(self.art_file)
|
||||
assert self.art_file.exists()
|
||||
|
||||
def test_delete_original_file(self):
|
||||
prev_move = config["import"]["move"].get()
|
||||
try:
|
||||
config["import"]["move"] = True
|
||||
self._fetch_art(True)
|
||||
self.assertNotExists(self.art_file)
|
||||
assert not self.art_file.exists()
|
||||
finally:
|
||||
config["import"]["move"] = prev_move
|
||||
|
||||
def test_do_not_delete_original_if_already_in_place(self):
|
||||
artdest = os.path.join(os.path.dirname(self.i.path), b"cover.jpg")
|
||||
shutil.copyfile(syspath(self.art_file), syspath(artdest))
|
||||
shutil.copyfile(self.art_file, syspath(artdest))
|
||||
self.afa_response = fetchart.Candidate(
|
||||
logger,
|
||||
source_name="test",
|
||||
|
|
@ -861,156 +860,135 @@ class ArtImporterTest(UseThePlugin):
|
|||
self.plugin.batch_fetch_art(
|
||||
self.lib, self.lib.albums(), force=False, quiet=False
|
||||
)
|
||||
self.assertExists(self.album.artpath)
|
||||
assert self.album.art_filepath.exists()
|
||||
|
||||
|
||||
class ArtForAlbumTest(UseThePlugin):
|
||||
"""Tests that fetchart.art_for_album respects the scale & filesize
|
||||
configurations (e.g., minwidth, enforce_ratio, max_filesize)
|
||||
class AlbumArtOperationTestCase(UseThePlugin):
|
||||
"""Base test case for album art operations.
|
||||
|
||||
Provides common setup for testing album art processing operations by setting
|
||||
up a mock filesystem source that returns a predefined test image.
|
||||
"""
|
||||
|
||||
IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg")
|
||||
IMG_348x348 = os.path.join(_common.RSRC, b"abbey-different.jpg")
|
||||
IMG_500x490 = os.path.join(_common.RSRC, b"abbey-similar.jpg")
|
||||
IMAGE_PATH = os.path.join(_common.RSRC, b"abbey-similar.jpg")
|
||||
IMAGE_FILESIZE = os.stat(util.syspath(IMAGE_PATH)).st_size
|
||||
IMAGE_WIDTH = 500
|
||||
IMAGE_HEIGHT = 490
|
||||
IMAGE_WIDTH_HEIGHT_DIFF = IMAGE_WIDTH - IMAGE_HEIGHT
|
||||
|
||||
IMG_225x225_SIZE = os.stat(util.syspath(IMG_225x225)).st_size
|
||||
IMG_348x348_SIZE = os.stat(util.syspath(IMG_348x348)).st_size
|
||||
|
||||
RESIZE_OP = "resize"
|
||||
DEINTERLACE_OP = "deinterlace"
|
||||
REFORMAT_OP = "reformat"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.old_fs_source_get = fetchart.FileSystem.get
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
def fs_source_get(_self, album, settings, paths):
|
||||
if paths:
|
||||
yield fetchart.Candidate(
|
||||
logger, source_name=_self.ID, path=self.image_file
|
||||
logger, source_name=_self.ID, path=cls.IMAGE_PATH
|
||||
)
|
||||
|
||||
fetchart.FileSystem.get = fs_source_get
|
||||
patch("beetsplug.fetchart.FileSystem.get", fs_source_get).start()
|
||||
cls.addClassCleanup(patch.stopall)
|
||||
|
||||
self.album = _common.Bag()
|
||||
def get_album_art(self):
|
||||
return self.plugin.art_for_album(_common.Bag(), [""], True)
|
||||
|
||||
def tearDown(self):
|
||||
fetchart.FileSystem.get = self.old_fs_source_get
|
||||
super().tearDown()
|
||||
|
||||
def assertImageIsValidArt(self, image_file, should_exist):
|
||||
self.assertExists(image_file)
|
||||
self.image_file = image_file
|
||||
class AlbumArtOperationConfigurationTest(AlbumArtOperationTestCase):
|
||||
"""Check that scale & filesize configuration is respected.
|
||||
|
||||
candidate = self.plugin.art_for_album(self.album, [""], True)
|
||||
Depending on `minwidth`, `enforce_ratio`, `margin_px`, and `margin_percent`
|
||||
configuration the plugin should or should not return an art candidate.
|
||||
"""
|
||||
|
||||
if should_exist:
|
||||
assert candidate is not None
|
||||
assert candidate.path == self.image_file
|
||||
self.assertExists(candidate.path)
|
||||
else:
|
||||
assert candidate is None
|
||||
def test_minwidth(self):
|
||||
self.plugin.minwidth = self.IMAGE_WIDTH / 2
|
||||
assert self.get_album_art()
|
||||
|
||||
def _assert_image_operated(self, image_file, operation, should_operate):
|
||||
self.image_file = image_file
|
||||
with patch.object(
|
||||
ArtResizer.shared, operation, return_value=self.image_file
|
||||
) as mock_operation:
|
||||
self.plugin.art_for_album(self.album, [""], True)
|
||||
assert mock_operation.called == should_operate
|
||||
self.plugin.minwidth = self.IMAGE_WIDTH * 2
|
||||
assert not self.get_album_art()
|
||||
|
||||
def _require_backend(self):
|
||||
"""Skip the test if the art resizer doesn't have ImageMagick or
|
||||
PIL (so comparisons and measurements are unavailable).
|
||||
"""
|
||||
if not ArtResizer.shared.local:
|
||||
self.skipTest("ArtResizer has no local imaging backend available")
|
||||
|
||||
def test_respect_minwidth(self):
|
||||
self._require_backend()
|
||||
self.plugin.minwidth = 300
|
||||
self.assertImageIsValidArt(self.IMG_225x225, False)
|
||||
self.assertImageIsValidArt(self.IMG_348x348, True)
|
||||
|
||||
def test_respect_enforce_ratio_yes(self):
|
||||
self._require_backend()
|
||||
def test_enforce_ratio(self):
|
||||
self.plugin.enforce_ratio = True
|
||||
self.assertImageIsValidArt(self.IMG_500x490, False)
|
||||
self.assertImageIsValidArt(self.IMG_225x225, True)
|
||||
assert not self.get_album_art()
|
||||
|
||||
def test_respect_enforce_ratio_no(self):
|
||||
self.plugin.enforce_ratio = False
|
||||
self.assertImageIsValidArt(self.IMG_500x490, True)
|
||||
assert self.get_album_art()
|
||||
|
||||
def test_respect_enforce_ratio_px_above(self):
|
||||
self._require_backend()
|
||||
def test_enforce_ratio_with_px_margin(self):
|
||||
self.plugin.enforce_ratio = True
|
||||
self.plugin.margin_px = 5
|
||||
self.assertImageIsValidArt(self.IMG_500x490, False)
|
||||
|
||||
def test_respect_enforce_ratio_px_below(self):
|
||||
self._require_backend()
|
||||
self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 0.5
|
||||
assert not self.get_album_art()
|
||||
|
||||
self.plugin.margin_px = self.IMAGE_WIDTH_HEIGHT_DIFF * 1.5
|
||||
assert self.get_album_art()
|
||||
|
||||
def test_enforce_ratio_with_percent_margin(self):
|
||||
self.plugin.enforce_ratio = True
|
||||
self.plugin.margin_px = 15
|
||||
self.assertImageIsValidArt(self.IMG_500x490, True)
|
||||
diff_by_width = self.IMAGE_WIDTH_HEIGHT_DIFF / self.IMAGE_WIDTH
|
||||
|
||||
def test_respect_enforce_ratio_percent_above(self):
|
||||
self._require_backend()
|
||||
self.plugin.enforce_ratio = True
|
||||
self.plugin.margin_percent = (500 - 490) / 500 * 0.5
|
||||
self.assertImageIsValidArt(self.IMG_500x490, False)
|
||||
self.plugin.margin_percent = diff_by_width * 0.5
|
||||
assert not self.get_album_art()
|
||||
|
||||
def test_respect_enforce_ratio_percent_below(self):
|
||||
self._require_backend()
|
||||
self.plugin.enforce_ratio = True
|
||||
self.plugin.margin_percent = (500 - 490) / 500 * 1.5
|
||||
self.assertImageIsValidArt(self.IMG_500x490, True)
|
||||
self.plugin.margin_percent = diff_by_width * 1.5
|
||||
assert self.get_album_art()
|
||||
|
||||
def test_resize_if_necessary(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 300
|
||||
self._assert_image_operated(self.IMG_225x225, self.RESIZE_OP, False)
|
||||
self._assert_image_operated(self.IMG_348x348, self.RESIZE_OP, True)
|
||||
|
||||
def test_fileresize(self):
|
||||
self._require_backend()
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assert_image_operated(self.IMG_225x225, self.RESIZE_OP, True)
|
||||
class AlbumArtPerformOperationTest(AlbumArtOperationTestCase):
|
||||
"""Test that the art is resized and deinterlaced if necessary."""
|
||||
|
||||
def test_fileresize_if_necessary(self):
|
||||
self._require_backend()
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE
|
||||
self._assert_image_operated(self.IMG_225x225, self.RESIZE_OP, False)
|
||||
self.assertImageIsValidArt(self.IMG_225x225, True)
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.resizer_mock = patch.object(
|
||||
ArtResizer.shared, "resize", return_value=self.IMAGE_PATH
|
||||
).start()
|
||||
self.deinterlacer_mock = patch.object(
|
||||
ArtResizer.shared, "deinterlace", return_value=self.IMAGE_PATH
|
||||
).start()
|
||||
|
||||
def test_fileresize_no_scale(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 300
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assert_image_operated(self.IMG_225x225, self.RESIZE_OP, True)
|
||||
def test_resize(self):
|
||||
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
||||
assert self.get_album_art()
|
||||
assert self.resizer_mock.called
|
||||
|
||||
def test_fileresize_and_scale(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 200
|
||||
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
|
||||
self._assert_image_operated(self.IMG_225x225, self.RESIZE_OP, True)
|
||||
def test_file_resized(self):
|
||||
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
||||
assert self.get_album_art()
|
||||
assert self.resizer_mock.called
|
||||
|
||||
def test_deinterlace(self):
|
||||
self._require_backend()
|
||||
def test_file_not_resized(self):
|
||||
self.plugin.max_filesize = self.IMAGE_FILESIZE
|
||||
assert self.get_album_art()
|
||||
assert not self.resizer_mock.called
|
||||
|
||||
def test_file_resized_but_not_scaled(self):
|
||||
self.plugin.maxwidth = self.IMAGE_WIDTH * 2
|
||||
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
||||
assert self.get_album_art()
|
||||
assert self.resizer_mock.called
|
||||
|
||||
def test_file_resized_and_scaled(self):
|
||||
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
||||
self.plugin.max_filesize = self.IMAGE_FILESIZE // 2
|
||||
assert self.get_album_art()
|
||||
assert self.resizer_mock.called
|
||||
|
||||
def test_deinterlaced(self):
|
||||
self.plugin.deinterlace = True
|
||||
self._assert_image_operated(self.IMG_225x225, self.DEINTERLACE_OP, True)
|
||||
assert self.get_album_art()
|
||||
assert self.deinterlacer_mock.called
|
||||
|
||||
def test_not_deinterlaced(self):
|
||||
self.plugin.deinterlace = False
|
||||
self._assert_image_operated(
|
||||
self.IMG_225x225, self.DEINTERLACE_OP, False
|
||||
)
|
||||
assert self.get_album_art()
|
||||
assert not self.deinterlacer_mock.called
|
||||
|
||||
def test_deinterlace_and_resize(self):
|
||||
self._require_backend()
|
||||
self.plugin.maxwidth = 300
|
||||
def test_deinterlaced_and_resized(self):
|
||||
self.plugin.maxwidth = self.IMAGE_WIDTH / 2
|
||||
self.plugin.deinterlace = True
|
||||
self._assert_image_operated(self.IMG_348x348, self.DEINTERLACE_OP, True)
|
||||
self._assert_image_operated(self.IMG_348x348, self.RESIZE_OP, True)
|
||||
assert self.get_album_art()
|
||||
assert self.deinterlacer_mock.called
|
||||
assert self.resizer_mock.called
|
||||
|
||||
|
||||
class DeprecatedConfigTest(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -14,19 +14,15 @@
|
|||
|
||||
"""Tests for BPD's implementation of the MPD protocol."""
|
||||
|
||||
import importlib.util
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
|
||||
# Mock GstPlayer so that the forked process doesn't attempt to import gi:
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import confuse
|
||||
import pytest
|
||||
|
|
@ -34,43 +30,8 @@ import yaml
|
|||
|
||||
from beets.test.helper import PluginTestCase
|
||||
from beets.util import bluelet
|
||||
from beetsplug import bpd
|
||||
|
||||
gstplayer = importlib.util.module_from_spec(
|
||||
importlib.util.find_spec("beetsplug.bpd.gstplayer")
|
||||
)
|
||||
|
||||
|
||||
def _gstplayer_play(*_):
|
||||
bpd.gstplayer._GstPlayer.playing = True
|
||||
return mock.DEFAULT
|
||||
|
||||
|
||||
gstplayer._GstPlayer = mock.MagicMock(
|
||||
spec_set=[
|
||||
"time",
|
||||
"volume",
|
||||
"playing",
|
||||
"run",
|
||||
"play_file",
|
||||
"pause",
|
||||
"stop",
|
||||
"seek",
|
||||
"play",
|
||||
"get_decoders",
|
||||
],
|
||||
**{
|
||||
"playing": False,
|
||||
"volume": 0,
|
||||
"time.return_value": (0, 0),
|
||||
"play_file.side_effect": _gstplayer_play,
|
||||
"play.side_effect": _gstplayer_play,
|
||||
"get_decoders.return_value": {"default": ({"audio/mpeg"}, {"mp3"})},
|
||||
},
|
||||
)
|
||||
gstplayer.GstPlayer = lambda _: gstplayer._GstPlayer
|
||||
sys.modules["beetsplug.bpd.gstplayer"] = gstplayer
|
||||
bpd.gstplayer = gstplayer
|
||||
bpd = pytest.importorskip("beetsplug.bpd")
|
||||
|
||||
|
||||
class CommandParseTest(unittest.TestCase):
|
||||
|
|
@ -256,7 +217,7 @@ def implements(commands, fail=False):
|
|||
bluelet_listener = bluelet.Listener
|
||||
|
||||
|
||||
@mock.patch("beets.util.bluelet.Listener")
|
||||
@patch("beets.util.bluelet.Listener")
|
||||
def start_server(args, assigned_port, listener_patch):
|
||||
"""Start the bpd server, writing the port to `assigned_port`."""
|
||||
|
||||
|
|
@ -311,7 +272,7 @@ class BPDTestHelper(PluginTestCase):
|
|||
"""
|
||||
# Create a config file:
|
||||
config = {
|
||||
"pluginpath": [os.fsdecode(self.temp_dir)],
|
||||
"pluginpath": [str(self.temp_dir_path)],
|
||||
"plugins": "bpd",
|
||||
# use port 0 to let the OS choose a free port
|
||||
"bpd": {"host": host, "port": 0, "control_port": 0},
|
||||
|
|
@ -320,7 +281,7 @@ class BPDTestHelper(PluginTestCase):
|
|||
config["bpd"]["password"] = password
|
||||
config_file = tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=os.fsdecode(self.temp_dir),
|
||||
dir=str(self.temp_dir_path),
|
||||
suffix=".yaml",
|
||||
delete=False,
|
||||
)
|
||||
|
|
@ -938,7 +899,7 @@ class BPDPlaylistsTest(BPDTestHelper):
|
|||
response = client.send_command("load", "anything")
|
||||
self._assert_failed(response, bpd.ERROR_NO_EXIST)
|
||||
|
||||
@unittest.skip
|
||||
@unittest.expectedFailure
|
||||
def test_cmd_playlistadd(self):
|
||||
with self.run_bpd() as client:
|
||||
self._bpd_add(client, self.item1, playlist="anything")
|
||||
|
|
@ -1128,7 +1089,7 @@ class BPDConnectionTest(BPDTestHelper):
|
|||
self._assert_ok(response)
|
||||
assert self.TAGTYPES == set(response.data["tagtype"])
|
||||
|
||||
@unittest.skip
|
||||
@unittest.expectedFailure
|
||||
def test_tagtypes_mask(self):
|
||||
with self.run_bpd() as client:
|
||||
response = client.send_command("tagtypes", "clear")
|
||||
|
|
@ -1169,6 +1130,10 @@ class BPDReflectionTest(BPDTestHelper):
|
|||
fail=True,
|
||||
)
|
||||
|
||||
@patch(
|
||||
"beetsplug.bpd.gstplayer.GstPlayer.get_decoders",
|
||||
MagicMock(return_value={"default": ({"audio/mpeg"}, {"mp3"})}),
|
||||
)
|
||||
def test_cmd_decoders(self):
|
||||
with self.run_bpd() as client:
|
||||
response = client.send_command("decoders")
|
||||
|
|
@ -18,6 +18,7 @@ import os.path
|
|||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mediafile import MediaFile
|
||||
|
|
@ -32,7 +33,6 @@ from beets.test.helper import (
|
|||
capture_log,
|
||||
control_stdin,
|
||||
)
|
||||
from beets.util import bytestring_path, displayable_path
|
||||
from beetsplug import convert
|
||||
|
||||
|
||||
|
|
@ -58,31 +58,11 @@ class ConvertMixin:
|
|||
shell_quote(sys.executable), shell_quote(stub), tag
|
||||
)
|
||||
|
||||
def assertFileTag(self, path, tag):
|
||||
"""Assert that the path is a file and the files content ends
|
||||
with `tag`.
|
||||
"""
|
||||
display_tag = tag
|
||||
tag = tag.encode("utf-8")
|
||||
self.assertIsFile(path)
|
||||
with open(path, "rb") as f:
|
||||
f.seek(-len(display_tag), os.SEEK_END)
|
||||
assert f.read() == tag, (
|
||||
f"{displayable_path(path)} is not tagged with {display_tag}"
|
||||
)
|
||||
|
||||
def assertNoFileTag(self, path, tag):
|
||||
"""Assert that the path is a file and the files content does not
|
||||
end with `tag`.
|
||||
"""
|
||||
display_tag = tag
|
||||
tag = tag.encode("utf-8")
|
||||
self.assertIsFile(path)
|
||||
with open(path, "rb") as f:
|
||||
f.seek(-len(tag), os.SEEK_END)
|
||||
assert f.read() != tag, (
|
||||
f"{displayable_path(path)} is unexpectedly tagged with {display_tag}"
|
||||
)
|
||||
def file_endswith(self, path: Path, tag: str):
|
||||
"""Check the path is a file and if its content ends with `tag`."""
|
||||
assert path.exists()
|
||||
assert path.is_file()
|
||||
return path.read_bytes().endswith(tag.encode("utf-8"))
|
||||
|
||||
|
||||
class ConvertTestCase(ConvertMixin, PluginTestCase):
|
||||
|
|
@ -106,7 +86,7 @@ class ImportConvertTest(AsIsImporterMixin, ImportHelper, ConvertTestCase):
|
|||
def test_import_converted(self):
|
||||
self.run_asis_importer()
|
||||
item = self.lib.items().get()
|
||||
self.assertFileTag(item.path, "convert")
|
||||
assert self.file_endswith(item.filepath, "convert")
|
||||
|
||||
# FIXME: fails on windows
|
||||
@unittest.skipIf(sys.platform == "win32", "win32")
|
||||
|
|
@ -117,7 +97,7 @@ class ImportConvertTest(AsIsImporterMixin, ImportHelper, ConvertTestCase):
|
|||
|
||||
item = self.lib.items().get()
|
||||
assert item is not None
|
||||
self.assertIsFile(item.path)
|
||||
assert item.filepath.is_file()
|
||||
|
||||
def test_delete_originals(self):
|
||||
self.config["convert"]["delete_originals"] = True
|
||||
|
|
@ -159,11 +139,10 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
self.album = self.add_album_fixture(ext="ogg")
|
||||
self.item = self.album.items()[0]
|
||||
|
||||
self.convert_dest = bytestring_path(
|
||||
os.path.join(self.temp_dir, b"convert_dest")
|
||||
)
|
||||
self.convert_dest = self.temp_dir_path / "convert_dest"
|
||||
self.converted_mp3 = self.convert_dest / "converted.mp3"
|
||||
self.config["convert"] = {
|
||||
"dest": self.convert_dest,
|
||||
"dest": str(self.convert_dest),
|
||||
"paths": {"default": "converted"},
|
||||
"format": "mp3",
|
||||
"formats": {
|
||||
|
|
@ -179,19 +158,16 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
def test_convert(self):
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
assert self.file_endswith(self.converted_mp3, "mp3")
|
||||
|
||||
def test_convert_with_auto_confirmation(self):
|
||||
self.run_convert("--yes")
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
assert self.file_endswith(self.converted_mp3, "mp3")
|
||||
|
||||
def test_reject_confirmation(self):
|
||||
with control_stdin("n"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertNotExists(converted)
|
||||
assert not self.converted_mp3.exists()
|
||||
|
||||
def test_convert_keep_new(self):
|
||||
assert os.path.splitext(self.item.path)[1] == b".ogg"
|
||||
|
|
@ -205,8 +181,7 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
def test_format_option(self):
|
||||
with control_stdin("y"):
|
||||
self.run_convert("--format", "opus")
|
||||
converted = os.path.join(self.convert_dest, b"converted.ops")
|
||||
self.assertFileTag(converted, "opus")
|
||||
assert self.file_endswith(self.convert_dest / "converted.ops", "opus")
|
||||
|
||||
def test_embed_album_art(self):
|
||||
self.config["convert"]["embed"] = True
|
||||
|
|
@ -218,12 +193,11 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
mediafile = MediaFile(converted)
|
||||
mediafile = MediaFile(self.converted_mp3)
|
||||
assert mediafile.images[0].data == image_data
|
||||
|
||||
def test_skip_existing(self):
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
converted = self.converted_mp3
|
||||
self.touch(converted, content="XXX")
|
||||
self.run_convert("--yes")
|
||||
with open(converted) as f:
|
||||
|
|
@ -231,8 +205,7 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
|
||||
def test_pretend(self):
|
||||
self.run_convert("--pretend")
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertNotExists(converted)
|
||||
assert not self.converted_mp3.exists()
|
||||
|
||||
def test_empty_query(self):
|
||||
with capture_log("beets.convert") as logs:
|
||||
|
|
@ -243,55 +216,51 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand):
|
|||
self.config["convert"]["max_bitrate"] = 5000
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
assert self.file_endswith(self.converted_mp3, "mp3")
|
||||
|
||||
def test_transcode_when_maxbr_set_low_and_different_formats(self):
|
||||
self.config["convert"]["max_bitrate"] = 5
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
assert self.file_endswith(self.converted_mp3, "mp3")
|
||||
|
||||
def test_transcode_when_maxbr_set_to_none_and_different_formats(self):
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
assert self.file_endswith(self.converted_mp3, "mp3")
|
||||
|
||||
def test_no_transcode_when_maxbr_set_high_and_same_formats(self):
|
||||
self.config["convert"]["max_bitrate"] = 5000
|
||||
self.config["convert"]["format"] = "ogg"
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.ogg")
|
||||
self.assertNoFileTag(converted, "ogg")
|
||||
assert not self.file_endswith(
|
||||
self.convert_dest / "converted.ogg", "ogg"
|
||||
)
|
||||
|
||||
def test_transcode_when_maxbr_set_low_and_same_formats(self):
|
||||
self.config["convert"]["max_bitrate"] = 5
|
||||
self.config["convert"]["format"] = "ogg"
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.ogg")
|
||||
self.assertFileTag(converted, "ogg")
|
||||
assert self.file_endswith(self.convert_dest / "converted.ogg", "ogg")
|
||||
|
||||
def test_transcode_when_maxbr_set_to_none_and_same_formats(self):
|
||||
self.config["convert"]["format"] = "ogg"
|
||||
with control_stdin("y"):
|
||||
self.run_convert()
|
||||
converted = os.path.join(self.convert_dest, b"converted.ogg")
|
||||
self.assertNoFileTag(converted, "ogg")
|
||||
assert not self.file_endswith(
|
||||
self.convert_dest / "converted.ogg", "ogg"
|
||||
)
|
||||
|
||||
def test_playlist(self):
|
||||
with control_stdin("y"):
|
||||
self.run_convert("--playlist", "playlist.m3u8")
|
||||
m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8")
|
||||
assert os.path.exists(m3u_created)
|
||||
assert (self.convert_dest / "playlist.m3u8").exists()
|
||||
|
||||
def test_playlist_pretend(self):
|
||||
self.run_convert("--playlist", "playlist.m3u8", "--pretend")
|
||||
m3u_created = os.path.join(self.convert_dest, b"playlist.m3u8")
|
||||
assert not os.path.exists(m3u_created)
|
||||
assert not (self.convert_dest / "playlist.m3u8").exists()
|
||||
|
||||
|
||||
@_common.slow_test()
|
||||
|
|
@ -301,9 +270,9 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
|
|||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.convert_dest = os.path.join(self.temp_dir, b"convert_dest")
|
||||
self.convert_dest = self.temp_dir_path / "convert_dest"
|
||||
self.config["convert"] = {
|
||||
"dest": self.convert_dest,
|
||||
"dest": str(self.convert_dest),
|
||||
"paths": {"default": "converted"},
|
||||
"never_convert_lossy_files": True,
|
||||
"format": "mp3",
|
||||
|
|
@ -316,23 +285,23 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand):
|
|||
[item] = self.add_item_fixtures(ext="flac")
|
||||
with control_stdin("y"):
|
||||
self.run_convert_path(item)
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
converted = self.convert_dest / "converted.mp3"
|
||||
assert self.file_endswith(converted, "mp3")
|
||||
|
||||
def test_transcode_from_lossy(self):
|
||||
self.config["convert"]["never_convert_lossy_files"] = False
|
||||
[item] = self.add_item_fixtures(ext="ogg")
|
||||
with control_stdin("y"):
|
||||
self.run_convert_path(item)
|
||||
converted = os.path.join(self.convert_dest, b"converted.mp3")
|
||||
self.assertFileTag(converted, "mp3")
|
||||
converted = self.convert_dest / "converted.mp3"
|
||||
assert self.file_endswith(converted, "mp3")
|
||||
|
||||
def test_transcode_from_lossy_prevented(self):
|
||||
[item] = self.add_item_fixtures(ext="ogg")
|
||||
with control_stdin("y"):
|
||||
self.run_convert_path(item)
|
||||
converted = os.path.join(self.convert_dest, b"converted.ogg")
|
||||
self.assertNoFileTag(converted, "mp3")
|
||||
converted = self.convert_dest / "converted.ogg"
|
||||
assert not self.file_endswith(converted, "mp3")
|
||||
|
||||
|
||||
class TestNoConvert:
|
||||
|
|
|
|||
|
|
@ -134,22 +134,6 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
{f: item[f] for f in item._fields} for item in self.album.items()
|
||||
]
|
||||
|
||||
def assertCounts(
|
||||
self,
|
||||
mock_write,
|
||||
album_count=ALBUM_COUNT,
|
||||
track_count=TRACK_COUNT,
|
||||
write_call_count=TRACK_COUNT,
|
||||
title_starts_with="",
|
||||
):
|
||||
"""Several common assertions on Album, Track and call counts."""
|
||||
assert len(self.lib.albums()) == album_count
|
||||
assert len(self.lib.items()) == track_count
|
||||
assert mock_write.call_count == write_call_count
|
||||
assert all(
|
||||
i.title.startswith(title_starts_with) for i in self.lib.items()
|
||||
)
|
||||
|
||||
def test_title_edit_discard(self, mock_write):
|
||||
"""Edit title for all items in the library, then discard changes."""
|
||||
# Edit track titles.
|
||||
|
|
@ -159,9 +143,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
["c"],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write, write_call_count=0, title_starts_with="t\u00eftle"
|
||||
)
|
||||
assert mock_write.call_count == 0
|
||||
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
|
||||
|
||||
def test_title_edit_apply(self, mock_write):
|
||||
|
|
@ -173,11 +155,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
["a"],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write,
|
||||
write_call_count=self.TRACK_COUNT,
|
||||
title_starts_with="modified t\u00eftle",
|
||||
)
|
||||
assert mock_write.call_count == self.TRACK_COUNT
|
||||
self.assertItemFieldsModified(
|
||||
self.album.items(), self.items_orig, ["title", "mtime"]
|
||||
)
|
||||
|
|
@ -191,10 +169,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
["a"],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write,
|
||||
write_call_count=1,
|
||||
)
|
||||
assert mock_write.call_count == 1
|
||||
# No changes except on last item.
|
||||
self.assertItemFieldsModified(
|
||||
list(self.album.items())[:-1], self.items_orig[:-1], []
|
||||
|
|
@ -210,9 +185,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
[],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write, write_call_count=0, title_starts_with="t\u00eftle"
|
||||
)
|
||||
assert mock_write.call_count == 0
|
||||
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
|
||||
|
||||
def test_album_edit_apply(self, mock_write):
|
||||
|
|
@ -226,7 +199,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
["a"],
|
||||
)
|
||||
|
||||
self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT)
|
||||
assert mock_write.call_count == self.TRACK_COUNT
|
||||
self.assertItemFieldsModified(
|
||||
self.album.items(), self.items_orig, ["album", "mtime"]
|
||||
)
|
||||
|
|
@ -249,9 +222,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
# Even though a flexible attribute was written (which is not directly
|
||||
# written to the tags), write should still be called since templates
|
||||
# might use it.
|
||||
self.assertCounts(
|
||||
mock_write, write_call_count=1, title_starts_with="t\u00eftle"
|
||||
)
|
||||
assert mock_write.call_count == 1
|
||||
|
||||
def test_a_album_edit_apply(self, mock_write):
|
||||
"""Album query (-a), edit album field, apply changes."""
|
||||
|
|
@ -263,7 +234,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
)
|
||||
|
||||
self.album.load()
|
||||
self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT)
|
||||
assert mock_write.call_count == self.TRACK_COUNT
|
||||
assert self.album.album == "modified \u00e4lbum"
|
||||
self.assertItemFieldsModified(
|
||||
self.album.items(), self.items_orig, ["album", "mtime"]
|
||||
|
|
@ -279,7 +250,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
)
|
||||
|
||||
self.album.load()
|
||||
self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT)
|
||||
assert mock_write.call_count == self.TRACK_COUNT
|
||||
assert self.album.albumartist == "the modified album artist"
|
||||
self.assertItemFieldsModified(
|
||||
self.album.items(), self.items_orig, ["albumartist", "mtime"]
|
||||
|
|
@ -295,9 +266,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
["n"],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write, write_call_count=0, title_starts_with="t\u00eftle"
|
||||
)
|
||||
assert mock_write.call_count == 0
|
||||
|
||||
def test_invalid_yaml(self, mock_write):
|
||||
"""Edit the yaml file incorrectly (resulting in a well-formed but
|
||||
|
|
@ -309,9 +278,7 @@ class EditCommandTest(EditMixin, BeetsTestCase):
|
|||
[],
|
||||
)
|
||||
|
||||
self.assertCounts(
|
||||
mock_write, write_call_count=0, title_starts_with="t\u00eftle"
|
||||
)
|
||||
assert mock_write.call_count == 0
|
||||
|
||||
|
||||
@_common.slow_test()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
|
@ -24,7 +25,12 @@ from mediafile import MediaFile
|
|||
|
||||
from beets import art, config, logging, ui
|
||||
from beets.test import _common
|
||||
from beets.test.helper import BeetsTestCase, FetchImageHelper, PluginMixin
|
||||
from beets.test.helper import (
|
||||
BeetsTestCase,
|
||||
FetchImageHelper,
|
||||
IOMixin,
|
||||
PluginMixin,
|
||||
)
|
||||
from beets.util import bytestring_path, displayable_path, syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from test.test_art_resize import DummyIMBackend
|
||||
|
|
@ -68,17 +74,13 @@ def require_artresizer_compare(test):
|
|||
return wrapper
|
||||
|
||||
|
||||
class EmbedartCliTest(PluginMixin, FetchImageHelper, BeetsTestCase):
|
||||
class EmbedartCliTest(IOMixin, PluginMixin, FetchImageHelper, BeetsTestCase):
|
||||
plugin = "embedart"
|
||||
small_artpath = os.path.join(_common.RSRC, b"image-2x3.jpg")
|
||||
abbey_artpath = os.path.join(_common.RSRC, b"abbey.jpg")
|
||||
abbey_similarpath = os.path.join(_common.RSRC, b"abbey-similar.jpg")
|
||||
abbey_differentpath = os.path.join(_common.RSRC, b"abbey-different.jpg")
|
||||
|
||||
def setUp(self):
|
||||
super().setUp() # Converter is threaded
|
||||
self.io.install()
|
||||
|
||||
def _setup_data(self, artpath=None):
|
||||
if not artpath:
|
||||
artpath = self.small_artpath
|
||||
|
|
@ -202,23 +204,21 @@ class EmbedartCliTest(PluginMixin, FetchImageHelper, BeetsTestCase):
|
|||
resource_path = os.path.join(_common.RSRC, b"image.mp3")
|
||||
album = self.add_album_fixture()
|
||||
trackpath = album.items()[0].path
|
||||
albumpath = album.path
|
||||
shutil.copy(syspath(resource_path), syspath(trackpath))
|
||||
|
||||
self.run_command("extractart", "-n", "extracted")
|
||||
|
||||
self.assertExists(os.path.join(albumpath, b"extracted.png"))
|
||||
assert (album.filepath / "extracted.png").exists()
|
||||
|
||||
def test_extracted_extension(self):
|
||||
resource_path = os.path.join(_common.RSRC, b"image-jpeg.mp3")
|
||||
album = self.add_album_fixture()
|
||||
trackpath = album.items()[0].path
|
||||
albumpath = album.path
|
||||
shutil.copy(syspath(resource_path), syspath(trackpath))
|
||||
|
||||
self.run_command("extractart", "-n", "extracted")
|
||||
|
||||
self.assertExists(os.path.join(albumpath, b"extracted.jpg"))
|
||||
assert (album.filepath / "extracted.jpg").exists()
|
||||
|
||||
def test_clear_art_with_yes_input(self):
|
||||
self._setup_data()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os.path
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
|
|
@ -74,8 +74,7 @@ class HookCommandTest(HookTestCase):
|
|||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
temp_dir = os.fsdecode(self.temp_dir)
|
||||
self.paths = [os.path.join(temp_dir, e) for e in self.events]
|
||||
self.paths = [str(self.temp_dir_path / e) for e in self.events]
|
||||
|
||||
def _test_command(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -68,26 +68,23 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase):
|
|||
"No MediaFile found for Item " + displayable_path(item.path)
|
||||
)
|
||||
|
||||
def assertEqualTimes(self, first, second, msg=None):
|
||||
"""For comparing file modification times at a sufficient precision"""
|
||||
assert first == pytest.approx(second, rel=1e-4), msg
|
||||
|
||||
def assertAlbumImport(self):
|
||||
def test_import_album_with_added_dates(self):
|
||||
self.importer.run()
|
||||
|
||||
album = self.lib.albums().get()
|
||||
assert album.added == self.min_mtime
|
||||
for item in album.items():
|
||||
assert item.added == self.min_mtime
|
||||
|
||||
def test_import_album_with_added_dates(self):
|
||||
self.assertAlbumImport()
|
||||
|
||||
def test_import_album_inplace_with_added_dates(self):
|
||||
self.config["import"]["copy"] = False
|
||||
self.config["import"]["move"] = False
|
||||
self.config["import"]["link"] = False
|
||||
self.config["import"]["hardlink"] = False
|
||||
self.assertAlbumImport()
|
||||
|
||||
self.importer.run()
|
||||
|
||||
album = self.lib.albums().get()
|
||||
assert album.added == self.min_mtime
|
||||
for item in album.items():
|
||||
assert item.added == self.min_mtime
|
||||
|
||||
def test_import_album_with_preserved_mtimes(self):
|
||||
self.config["importadded"]["preserve_mtimes"] = True
|
||||
|
|
@ -95,10 +92,12 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase):
|
|||
album = self.lib.albums().get()
|
||||
assert album.added == self.min_mtime
|
||||
for item in album.items():
|
||||
self.assertEqualTimes(item.added, self.min_mtime)
|
||||
assert item.added == pytest.approx(self.min_mtime, rel=1e-4)
|
||||
mediafile_mtime = os.path.getmtime(self.find_media_file(item).path)
|
||||
self.assertEqualTimes(item.mtime, mediafile_mtime)
|
||||
self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime)
|
||||
assert item.mtime == pytest.approx(mediafile_mtime, rel=1e-4)
|
||||
assert os.path.getmtime(item.path) == pytest.approx(
|
||||
mediafile_mtime, rel=1e-4
|
||||
)
|
||||
|
||||
def test_reimported_album_skipped(self):
|
||||
# Import and record the original added dates
|
||||
|
|
@ -113,22 +112,21 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase):
|
|||
self.importer.run()
|
||||
# Verify the reimported items
|
||||
album = self.lib.albums().get()
|
||||
self.assertEqualTimes(album.added, album_added_before)
|
||||
assert album.added == pytest.approx(album_added_before, rel=1e-4)
|
||||
items_added_after = {item.path: item.added for item in album.items()}
|
||||
for item_path, added_after in items_added_after.items():
|
||||
self.assertEqualTimes(
|
||||
items_added_before[item_path],
|
||||
added_after,
|
||||
"reimport modified Item.added for "
|
||||
+ displayable_path(item_path),
|
||||
)
|
||||
assert items_added_before[item_path] == pytest.approx(
|
||||
added_after, rel=1e-4
|
||||
), "reimport modified Item.added for " + displayable_path(item_path)
|
||||
|
||||
def test_import_singletons_with_added_dates(self):
|
||||
self.config["import"]["singletons"] = True
|
||||
self.importer.run()
|
||||
for item in self.lib.items():
|
||||
mfile = self.find_media_file(item)
|
||||
self.assertEqualTimes(item.added, os.path.getmtime(mfile.path))
|
||||
assert item.added == pytest.approx(
|
||||
os.path.getmtime(mfile.path), rel=1e-4
|
||||
)
|
||||
|
||||
def test_import_singletons_with_preserved_mtimes(self):
|
||||
self.config["import"]["singletons"] = True
|
||||
|
|
@ -136,9 +134,11 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase):
|
|||
self.importer.run()
|
||||
for item in self.lib.items():
|
||||
mediafile_mtime = os.path.getmtime(self.find_media_file(item).path)
|
||||
self.assertEqualTimes(item.added, mediafile_mtime)
|
||||
self.assertEqualTimes(item.mtime, mediafile_mtime)
|
||||
self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime)
|
||||
assert item.added == pytest.approx(mediafile_mtime, rel=1e-4)
|
||||
assert item.mtime == pytest.approx(mediafile_mtime, rel=1e-4)
|
||||
assert os.path.getmtime(item.path) == pytest.approx(
|
||||
mediafile_mtime, rel=1e-4
|
||||
)
|
||||
|
||||
def test_reimported_singletons_skipped(self):
|
||||
self.config["import"]["singletons"] = True
|
||||
|
|
@ -155,9 +155,6 @@ class ImportAddedTest(PluginMixin, AutotagImportTestCase):
|
|||
# Verify the reimported items
|
||||
items_added_after = {item.path: item.added for item in self.lib.items()}
|
||||
for item_path, added_after in items_added_after.items():
|
||||
self.assertEqualTimes(
|
||||
items_added_before[item_path],
|
||||
added_after,
|
||||
"reimport modified Item.added for "
|
||||
+ displayable_path(item_path),
|
||||
)
|
||||
assert items_added_before[item_path] == pytest.approx(
|
||||
added_after, rel=1e-4
|
||||
), "reimport modified Item.added for " + displayable_path(item_path)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ class ImportfeedsTestTest(BeetsTestCase):
|
|||
def setUp(self):
|
||||
super().setUp()
|
||||
self.importfeeds = ImportFeedsPlugin()
|
||||
self.feeds_dir = os.path.join(os.fsdecode(self.temp_dir), "importfeeds")
|
||||
config["importfeeds"]["dir"] = self.feeds_dir
|
||||
self.feeds_dir = self.temp_dir_path / "importfeeds"
|
||||
config["importfeeds"]["dir"] = str(self.feeds_dir)
|
||||
|
||||
def test_multi_format_album_playlist(self):
|
||||
config["importfeeds"]["formats"] = "m3u_multi"
|
||||
|
|
@ -24,10 +24,8 @@ class ImportfeedsTestTest(BeetsTestCase):
|
|||
self.lib.add(item)
|
||||
|
||||
self.importfeeds.album_imported(self.lib, album)
|
||||
playlist_path = os.path.join(
|
||||
self.feeds_dir, os.listdir(self.feeds_dir)[0]
|
||||
)
|
||||
assert playlist_path.endswith("album_name.m3u")
|
||||
playlist_path = self.feeds_dir / next(self.feeds_dir.iterdir())
|
||||
assert str(playlist_path).endswith("album_name.m3u")
|
||||
with open(playlist_path) as playlist:
|
||||
assert item_path in playlist.read()
|
||||
|
||||
|
|
@ -43,9 +41,7 @@ class ImportfeedsTestTest(BeetsTestCase):
|
|||
self.lib.add(item)
|
||||
|
||||
self.importfeeds.album_imported(self.lib, album)
|
||||
playlist = os.path.join(
|
||||
self.feeds_dir, config["importfeeds"]["m3u_name"].get()
|
||||
)
|
||||
playlist = self.feeds_dir / config["importfeeds"]["m3u_name"].get()
|
||||
playlist_subdir = os.path.dirname(playlist)
|
||||
assert os.path.isdir(playlist_subdir)
|
||||
assert os.path.isfile(playlist)
|
||||
|
|
@ -62,7 +58,7 @@ class ImportfeedsTestTest(BeetsTestCase):
|
|||
self.importfeeds.import_begin(self)
|
||||
self.importfeeds.album_imported(self.lib, album)
|
||||
date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M")
|
||||
playlist = os.path.join(self.feeds_dir, f"imports_{date}.m3u")
|
||||
playlist = self.feeds_dir / f"imports_{date}.m3u"
|
||||
assert os.path.isfile(playlist)
|
||||
with open(playlist) as playlist_contents:
|
||||
assert item_path in playlist_contents.read()
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class MbsyncCliTest(PluginTestCase):
|
|||
plugin = "mbsync"
|
||||
|
||||
@patch(
|
||||
"beets.plugins.album_for_id",
|
||||
"beets.metadata_plugins.album_for_id",
|
||||
Mock(
|
||||
side_effect=lambda *_: AlbumInfo(
|
||||
album_id="album id",
|
||||
|
|
@ -33,7 +33,7 @@ class MbsyncCliTest(PluginTestCase):
|
|||
),
|
||||
)
|
||||
@patch(
|
||||
"beets.plugins.track_for_id",
|
||||
"beets.metadata_plugins.track_for_id",
|
||||
Mock(
|
||||
side_effect=lambda *_: TrackInfo(
|
||||
track_id="singleton id", title="new title"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from unittest.mock import Mock, patch
|
|||
|
||||
from beets.test._common import touch
|
||||
from beets.test.helper import AsIsImporterMixin, ImportTestCase, PluginMixin
|
||||
from beets.util import displayable_path
|
||||
from beetsplug.permissions import (
|
||||
check_permissions,
|
||||
convert_perm,
|
||||
|
|
@ -23,57 +22,25 @@ class PermissionsPluginTest(AsIsImporterMixin, PluginMixin, ImportTestCase):
|
|||
self.config["permissions"] = {"file": "777", "dir": "777"}
|
||||
|
||||
def test_permissions_on_album_imported(self):
|
||||
self.do_thing(True)
|
||||
self.import_and_check_permissions()
|
||||
|
||||
def test_permissions_on_item_imported(self):
|
||||
self.config["import"]["singletons"] = True
|
||||
self.do_thing(True)
|
||||
self.import_and_check_permissions()
|
||||
|
||||
@patch("os.chmod", Mock())
|
||||
def test_failing_to_set_permissions(self):
|
||||
self.do_thing(False)
|
||||
|
||||
def do_thing(self, expect_success):
|
||||
def import_and_check_permissions(self):
|
||||
if platform.system() == "Windows":
|
||||
self.skipTest("permissions not available on Windows")
|
||||
|
||||
def get_stat(v):
|
||||
return (
|
||||
os.stat(os.path.join(self.temp_dir, b"import", *v)).st_mode
|
||||
& 0o777
|
||||
)
|
||||
|
||||
typs = ["file", "dir"]
|
||||
|
||||
track_file = (b"album", b"track_1.mp3")
|
||||
self.exp_perms = {
|
||||
True: {
|
||||
k: convert_perm(self.config["permissions"][k].get())
|
||||
for k in typs
|
||||
},
|
||||
False: {k: get_stat(v) for (k, v) in zip(typs, (track_file, ()))},
|
||||
}
|
||||
track_file = os.path.join(self.import_dir, b"album", b"track_1.mp3")
|
||||
assert os.stat(track_file).st_mode & 0o777 != 511
|
||||
|
||||
self.run_asis_importer()
|
||||
item = self.lib.items().get()
|
||||
|
||||
self.assertPerms(item.path, "file", expect_success)
|
||||
|
||||
for path in dirs_in_library(self.lib.directory, item.path):
|
||||
self.assertPerms(path, "dir", expect_success)
|
||||
|
||||
def assertPerms(self, path, typ, expect_success):
|
||||
for x in [
|
||||
(True, self.exp_perms[expect_success][typ], "!="),
|
||||
(False, self.exp_perms[not expect_success][typ], "=="),
|
||||
]:
|
||||
msg = "{} : {} {} {}".format(
|
||||
displayable_path(path),
|
||||
oct(os.stat(path).st_mode),
|
||||
x[2],
|
||||
oct(x[1]),
|
||||
)
|
||||
assert x[0] == check_permissions(path, x[1]), msg
|
||||
paths = (item.path, *dirs_in_library(self.lib.directory, item.path))
|
||||
for path in paths:
|
||||
assert os.stat(path).st_mode & 0o777 == 511
|
||||
|
||||
def test_convert_perm_from_string(self):
|
||||
assert convert_perm("10") == 8
|
||||
|
|
|
|||
|
|
@ -72,12 +72,10 @@ class PlaylistTestCase(PluginTestCase):
|
|||
self.lib.add(i3)
|
||||
self.lib.add_album([i3])
|
||||
|
||||
self.playlist_dir = os.path.join(
|
||||
os.fsdecode(self.temp_dir), "playlists"
|
||||
)
|
||||
os.makedirs(self.playlist_dir)
|
||||
self.playlist_dir = self.temp_dir_path / "playlists"
|
||||
self.playlist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config["directory"] = self.music_dir
|
||||
self.config["playlist"]["playlist_dir"] = self.playlist_dir
|
||||
self.config["playlist"]["playlist_dir"] = str(self.playlist_dir)
|
||||
|
||||
self.setup_test()
|
||||
self.load_plugins()
|
||||
|
|
@ -222,7 +220,7 @@ class PlaylistTestRelativeToPls(PlaylistQueryTest, PlaylistTestCase):
|
|||
)
|
||||
|
||||
self.config["playlist"]["relative_to"] = "playlist"
|
||||
self.config["playlist"]["playlist_dir"] = self.playlist_dir
|
||||
self.config["playlist"]["playlist_dir"] = str(self.playlist_dir)
|
||||
|
||||
|
||||
class PlaylistUpdateTest:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
from os import fsdecode, path, remove
|
||||
from os import path, remove
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from tempfile import mkdtemp
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock
|
||||
|
|
@ -26,7 +27,7 @@ from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort
|
|||
from beets.library import Album, Item, parse_query_string
|
||||
from beets.test.helper import BeetsTestCase, PluginTestCase
|
||||
from beets.ui import UserError
|
||||
from beets.util import CHAR_REPLACE, bytestring_path, syspath
|
||||
from beets.util import CHAR_REPLACE, syspath
|
||||
from beetsplug.smartplaylist import SmartPlaylistPlugin
|
||||
|
||||
|
||||
|
|
@ -165,9 +166,9 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
|
||||
spl._matched_playlists = [pl]
|
||||
|
||||
dir = bytestring_path(mkdtemp())
|
||||
dir = mkdtemp()
|
||||
config["smartplaylist"]["relative_to"] = False
|
||||
config["smartplaylist"]["playlist_dir"] = fsdecode(dir)
|
||||
config["smartplaylist"]["playlist_dir"] = str(dir)
|
||||
try:
|
||||
spl.update_playlists(lib)
|
||||
except Exception:
|
||||
|
|
@ -177,10 +178,9 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
lib.items.assert_called_once_with(q, None)
|
||||
lib.albums.assert_called_once_with(a_q, None)
|
||||
|
||||
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
|
||||
self.assertExists(m3u_filepath)
|
||||
with open(syspath(m3u_filepath), "rb") as f:
|
||||
content = f.read()
|
||||
m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u")
|
||||
assert m3u_filepath.exists()
|
||||
content = m3u_filepath.read_bytes()
|
||||
rmtree(syspath(dir))
|
||||
|
||||
assert content == b"/tagada.mp3\n"
|
||||
|
|
@ -208,11 +208,11 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
|
||||
spl._matched_playlists = [pl]
|
||||
|
||||
dir = bytestring_path(mkdtemp())
|
||||
dir = mkdtemp()
|
||||
config["smartplaylist"]["output"] = "extm3u"
|
||||
config["smartplaylist"]["prefix"] = "http://beets:8337/files"
|
||||
config["smartplaylist"]["relative_to"] = False
|
||||
config["smartplaylist"]["playlist_dir"] = fsdecode(dir)
|
||||
config["smartplaylist"]["playlist_dir"] = str(dir)
|
||||
try:
|
||||
spl.update_playlists(lib)
|
||||
except Exception:
|
||||
|
|
@ -222,10 +222,9 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
lib.items.assert_called_once_with(q, None)
|
||||
lib.albums.assert_called_once_with(a_q, None)
|
||||
|
||||
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
|
||||
self.assertExists(m3u_filepath)
|
||||
with open(syspath(m3u_filepath), "rb") as f:
|
||||
content = f.read()
|
||||
m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u")
|
||||
assert m3u_filepath.exists()
|
||||
content = m3u_filepath.read_bytes()
|
||||
rmtree(syspath(dir))
|
||||
|
||||
assert (
|
||||
|
|
@ -260,10 +259,10 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
|
||||
spl._matched_playlists = [pl]
|
||||
|
||||
dir = bytestring_path(mkdtemp())
|
||||
dir = mkdtemp()
|
||||
config["smartplaylist"]["output"] = "extm3u"
|
||||
config["smartplaylist"]["relative_to"] = False
|
||||
config["smartplaylist"]["playlist_dir"] = fsdecode(dir)
|
||||
config["smartplaylist"]["playlist_dir"] = str(dir)
|
||||
config["smartplaylist"]["fields"] = ["id", "genre"]
|
||||
try:
|
||||
spl.update_playlists(lib)
|
||||
|
|
@ -274,10 +273,9 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
lib.items.assert_called_once_with(q, None)
|
||||
lib.albums.assert_called_once_with(a_q, None)
|
||||
|
||||
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
|
||||
self.assertExists(m3u_filepath)
|
||||
with open(syspath(m3u_filepath), "rb") as f:
|
||||
content = f.read()
|
||||
m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u")
|
||||
assert m3u_filepath.exists()
|
||||
content = m3u_filepath.read_bytes()
|
||||
rmtree(syspath(dir))
|
||||
|
||||
assert (
|
||||
|
|
@ -307,10 +305,10 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
pl = b"$title-my<playlist>.m3u", (q, None), (a_q, None)
|
||||
spl._matched_playlists = [pl]
|
||||
|
||||
dir = bytestring_path(mkdtemp())
|
||||
dir = mkdtemp()
|
||||
tpl = "http://beets:8337/item/$id/file"
|
||||
config["smartplaylist"]["uri_format"] = tpl
|
||||
config["smartplaylist"]["playlist_dir"] = fsdecode(dir)
|
||||
config["smartplaylist"]["playlist_dir"] = dir
|
||||
# The following options should be ignored when uri_format is set
|
||||
config["smartplaylist"]["relative_to"] = "/data"
|
||||
config["smartplaylist"]["prefix"] = "/prefix"
|
||||
|
|
@ -324,10 +322,9 @@ class SmartPlaylistTest(BeetsTestCase):
|
|||
lib.items.assert_called_once_with(q, None)
|
||||
lib.albums.assert_called_once_with(a_q, None)
|
||||
|
||||
m3u_filepath = path.join(dir, b"ta_ga_da-my_playlist_.m3u")
|
||||
self.assertExists(m3u_filepath)
|
||||
with open(syspath(m3u_filepath), "rb") as f:
|
||||
content = f.read()
|
||||
m3u_filepath = Path(dir, "ta_ga_da-my_playlist_.m3u")
|
||||
assert m3u_filepath.exists()
|
||||
content = m3u_filepath.read_bytes()
|
||||
rmtree(syspath(dir))
|
||||
|
||||
assert content == b"http://beets:8337/item/3/file\n"
|
||||
|
|
@ -346,22 +343,20 @@ class SmartPlaylistCLITest(PluginTestCase):
|
|||
{"name": "all.m3u", "query": ""},
|
||||
]
|
||||
)
|
||||
config["smartplaylist"]["playlist_dir"].set(fsdecode(self.temp_dir))
|
||||
config["smartplaylist"]["playlist_dir"].set(str(self.temp_dir_path))
|
||||
|
||||
def test_splupdate(self):
|
||||
with pytest.raises(UserError):
|
||||
self.run_with_output("splupdate", "tagada")
|
||||
|
||||
self.run_with_output("splupdate", "my_playlist")
|
||||
m3u_path = path.join(self.temp_dir, b"my_playlist.m3u")
|
||||
self.assertExists(m3u_path)
|
||||
with open(syspath(m3u_path), "rb") as f:
|
||||
assert f.read() == self.item.path + b"\n"
|
||||
m3u_path = self.temp_dir_path / "my_playlist.m3u"
|
||||
assert m3u_path.exists()
|
||||
assert m3u_path.read_bytes() == self.item.path + b"\n"
|
||||
remove(syspath(m3u_path))
|
||||
|
||||
self.run_with_output("splupdate", "my_playlist.m3u")
|
||||
with open(syspath(m3u_path), "rb") as f:
|
||||
assert f.read() == self.item.path + b"\n"
|
||||
assert m3u_path.read_bytes() == self.item.path + b"\n"
|
||||
remove(syspath(m3u_path))
|
||||
|
||||
self.run_with_output("splupdate")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import responses
|
|||
|
||||
from beets.library import Item
|
||||
from beets.test import _common
|
||||
from beets.test.helper import BeetsTestCase
|
||||
from beets.test.helper import PluginTestCase
|
||||
from beetsplug import spotify
|
||||
|
||||
|
||||
|
|
@ -23,10 +23,11 @@ def _params(url):
|
|||
return parse_qs(urlparse(url).query)
|
||||
|
||||
|
||||
class SpotifyPluginTest(BeetsTestCase):
|
||||
class SpotifyPluginTest(PluginTestCase):
|
||||
plugin = "spotify"
|
||||
|
||||
@responses.activate
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
spotify.SpotifyPlugin.oauth_token_url,
|
||||
|
|
@ -39,6 +40,7 @@ class SpotifyPluginTest(BeetsTestCase):
|
|||
"scope": "",
|
||||
},
|
||||
)
|
||||
super().setUp()
|
||||
self.spotify = spotify.SpotifyPlugin()
|
||||
opts = ArgumentsMock("list", False)
|
||||
self.spotify._parse_opts(opts)
|
||||
|
|
@ -176,3 +178,74 @@ class SpotifyPluginTest(BeetsTestCase):
|
|||
results = self.spotify._match_library_tracks(self.lib, "Happy")
|
||||
assert 1 == len(results)
|
||||
assert "6NPVjNh8Jhru9xOmyQigds" == results[0]["id"]
|
||||
|
||||
@responses.activate
|
||||
def test_japanese_track(self):
|
||||
"""Ensure non-ASCII characters remain unchanged in search queries"""
|
||||
|
||||
# Path to the mock JSON file for the Japanese track
|
||||
json_file = os.path.join(
|
||||
_common.RSRC, b"spotify", b"japanese_track_request.json"
|
||||
)
|
||||
|
||||
# Load the mock JSON response
|
||||
with open(json_file, "rb") as f:
|
||||
response_body = f.read()
|
||||
|
||||
# Mock Spotify Search API response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
spotify.SpotifyPlugin.search_url,
|
||||
body=response_body,
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Create a mock item with Japanese metadata
|
||||
item = Item(
|
||||
mb_trackid="56789",
|
||||
album="盗作",
|
||||
albumartist="ヨルシカ",
|
||||
title="思想犯",
|
||||
length=10,
|
||||
)
|
||||
item.add(self.lib)
|
||||
|
||||
# Search without ascii encoding
|
||||
|
||||
with self.configure_plugin(
|
||||
{
|
||||
"search_query_ascii": False,
|
||||
}
|
||||
):
|
||||
assert self.spotify.config["search_query_ascii"].get() is False
|
||||
# Call the method to match library tracks
|
||||
results = self.spotify._match_library_tracks(self.lib, item.title)
|
||||
|
||||
# Assertions to verify results
|
||||
assert results is not None
|
||||
assert 1 == len(results)
|
||||
assert results[0]["name"] == item.title
|
||||
assert results[0]["artists"][0]["name"] == item.albumartist
|
||||
assert results[0]["album"]["name"] == item.album
|
||||
|
||||
# Verify search query parameters
|
||||
params = _params(responses.calls[0].request.url)
|
||||
query = params["q"][0]
|
||||
assert item.title in query
|
||||
assert f"artist:{item.albumartist}" in query
|
||||
assert f"album:{item.album}" in query
|
||||
assert not query.isascii()
|
||||
|
||||
# Is not found in the library if ascii encoding is enabled
|
||||
with self.configure_plugin(
|
||||
{
|
||||
"search_query_ascii": True,
|
||||
}
|
||||
):
|
||||
assert self.spotify.config["search_query_ascii"].get() is True
|
||||
results = self.spotify._match_library_tracks(self.lib, item.title)
|
||||
params = _params(responses.calls[1].request.url)
|
||||
query = params["q"][0]
|
||||
|
||||
assert query.isascii()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue