Merge branch 'master' into master

This commit is contained in:
rdy2go 2025-07-23 19:44:17 +02:00 committed by GitHub
commit 4e2ebabb92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 4312 additions and 4877 deletions

View file

@ -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

View file

@ -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 }}'

View file

@ -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' }}

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
import lap
import numpy as np
from beets import config, logging, 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)

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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
View 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",
]

View 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
View 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
View 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
View 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)

View file

@ -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
)

View file

@ -63,8 +63,8 @@ HAVE_SYMLINK = sys.platform != "win32"
HAVE_HARDLINK = sys.platform != "win32"
def item(lib=None):
i = beets.library.Item(
def item(lib=None, **kwargs):
defaults = dict(
title="the title",
artist="the artist",
albumartist="the album artist",
@ -99,6 +99,7 @@ def item(lib=None):
album_id=None,
mtime=12345,
)
i = beets.library.Item(**{**defaults, **kwargs})
if lib:
lib.add(i)
return i
@ -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.

View file

@ -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()

View file

@ -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.

View file

@ -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")

View file

@ -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]):

View file

@ -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)

View file

@ -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

View file

@ -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
View 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"

View file

@ -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)

View file

@ -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(),

View file

@ -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(
[

View file

@ -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)

View file

@ -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):

View file

@ -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)

View file

@ -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."""

View file

@ -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]

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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, ""))

View file

@ -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

View file

@ -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"])

View file

@ -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("=")

View file

@ -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.")

View file

@ -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

View file

@ -144,7 +144,7 @@ class ExportPlugin(BeetsPlugin):
items = []
for data_emitter in data_collector(
lib,
ui.decargs(args),
args,
album=opts.album,
):
try:

View file

@ -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]

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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())

View file

@ -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(

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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
)

View file

@ -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:

View file

@ -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"> \

View file

@ -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):

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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):

View file

@ -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(

View file

@ -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

View file

@ -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)
)

View file

@ -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")

View file

@ -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

View file

@ -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):

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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)
--------------------

View file

@ -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
--------

View file

@ -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
-----------------

View file

@ -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: [
{

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"]

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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")

View file

@ -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:

View file

@ -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()

View file

@ -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()

View file

@ -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,

View file

@ -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)

View file

@ -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()

View file

@ -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"

View file

@ -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

View file

@ -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:

View file

@ -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")

View file

@ -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