Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 8 additions & 17 deletions beets/dbcore/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,8 @@ class DBAccessError(Exception):
"""The SQLite database became inaccessible.

This can happen when trying to read or write the database when, for
example, the database file is deleted, the parent directory is missing,
or the file permissions prevent the operation. There is probably no way
to recover from this error.
example, the database file is deleted or otherwise disappears. There
is probably no way to recover from this error.
"""


Expand Down Expand Up @@ -1025,17 +1024,11 @@ def _handle_mutate(self) -> Iterator[None]:
# In two specific cases, SQLite reports an error while accessing
# the underlying database file. We surface these exceptions as
# DBAccessError so the application can abort.
if e.args[0] == "unable to open database file":
raise DBAccessError(
"unable to open database file. "
"Check that the parent directory exists and is writable."
)
elif e.args[0] == "attempt to write a readonly database":
raise DBAccessError(
"attempt to write a readonly database. "
"Check file permissions: the database file or its directory "
"may not be writable."
)
if e.args[0] in (
"attempt to write a readonly database",
"unable to open database file",
):
raise DBAccessError(e.args[0])
raise
else:
self._mutated = True
Expand Down Expand Up @@ -1065,8 +1058,6 @@ def script(self, statements: str):
class Migration(ABC):
"""Define a one-time data migration that runs during database startup."""

CHUNK_SIZE: ClassVar[int] = 1000

db: Database
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grug see Migration.CHUNK_SIZE removed. but many migrations use self.CHUNK_SIZE (ex MultiValueFieldMigration). now migration run will crash with AttributeError unless every subclass set CHUNK_SIZE. put default back (or move constant into base class those migrations share).

Suggested change
db: Database
db: Database
CHUNK_SIZE: ClassVar[int] = 1000

Copilot uses AI. Check for mistakes.

@cached_classproperty
Expand Down Expand Up @@ -1124,7 +1115,7 @@ class Database:
data is written in a transaction.
"""

def __init__(self, path, timeout: float = 5.0):
def __init__(self, path, timeout: float = 30.0):
if sqlite3.threadsafety == 0:
raise RuntimeError(
"sqlite3 must be compiled with multi-threading support"
Expand Down
38 changes: 29 additions & 9 deletions beets/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ def parse_subcommand(self, args):


def _setup(
options: optparse.Values,
options: optparse.Values, lib: library.Library | None
) -> tuple[list[Subcommand], library.Library]:
"""Prepare and global state and updates it with command line options.

Expand All @@ -821,8 +821,9 @@ def _setup(
subcommands = list(default_commands)
subcommands.extend(plugins.commands())

lib = _open_library(config)
plugins.send("library_opened", lib=lib)
if lib is None:
lib = _open_library(config)
plugins.send("library_opened", lib=lib)

return subcommands, lib

Expand Down Expand Up @@ -890,9 +891,25 @@ def _open_library(config: confuse.LazyConfig) -> library.Library:
lib.get_item(0) # Test database connection.
except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error:
log.debug("{}", traceback.format_exc())
# Check for permission-related errors and provide a helpful message
error_str = str(db_error).lower()
dbpath_display = util.displayable_path(dbpath)
if "unable to open" in error_str:
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grug see code say in PR text handle 'readonly' too, but here only check for 'unable to open'. so readonly write-permission error still get generic message. grug want include readonly/read-only strings too (maybe 'attempt to write a readonly database').

Suggested change
if "unable to open" in error_str:
permission_error_strings = (
"unable to open",
"readonly",
"read-only",
"attempt to write a readonly database",
)
if any(msg in error_str for msg in permission_error_strings):

Copilot uses AI. Check for mistakes.
# Normalize path and get directory
normalized_path = os.path.abspath(dbpath)
db_dir = os.path.dirname(normalized_path)
# Handle edge case where path has no directory component
if not db_dir:
db_dir = b"."
raise UserError(
f"database file {dbpath_display} could not be opened. "
f"This may be due to a permissions issue. If the database "
f"does not exist yet, please check that the file or directory "
f"{util.displayable_path(db_dir)} is writable "
f"(original error: {db_error})."
)
raise UserError(
f"database file {util.displayable_path(dbpath)} cannot not be"
f" opened: {db_error}"
f"database file {dbpath_display} could not be opened: {db_error}"
)
log.debug(
"library database: {}\nlibrary directory: {}",
Expand All @@ -902,7 +919,7 @@ def _open_library(config: confuse.LazyConfig) -> library.Library:
return lib


def _raw_main(args: list[str] | None) -> None:
def _raw_main(args: list[str], lib=None) -> None:
"""A helper function for `main` without top-level exception
handling.
"""
Expand Down Expand Up @@ -983,17 +1000,20 @@ def parse_csl_callback(

return config_edit(options)

subcommands, lib = _setup(options)
test_lib = bool(lib)
subcommands, lib = _setup(options, lib)
parser.add_subcommand(*subcommands)

subcommand, suboptions, subargs = parser.parse_subcommand(subargs)
subcommand.func(lib, suboptions, subargs)

plugins.send("cli_exit", lib=lib)
lib._close()
if not test_lib:
# Clean up the library unless it came from the test harness.
lib._close()


def main(args: list[str] | None = None) -> None:
def main(args=None):
"""Run the main command-line interface for beets. Includes top-level
exception handlers that print friendly error messages.
"""
Expand Down
178 changes: 11 additions & 167 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,155 +12,6 @@ Unreleased
New features
~~~~~~~~~~~~

- :doc:`plugins/smartplaylist`: The ``splupdate`` command output is
restructured. The per-playlist summary now includes a track count. Per-track
details are shown only when ``-v`` flag is provided (``beet -v splupdate``).
The ``--pretend`` flag produces the same output but reports *"N playlists
would be updated"* instead of *"N playlists updated"*. The ``--format`` option
allows customizing the track line format. The ``--pretend-paths`` option was
removed (use ``--format='$path'`` instead). :bug:`6183`
- :ref:`import-cmd`: When importing an archive (zip, tar, rar, or 7z) with
``move: yes``, the source archive is now removed after a successful import.
Archives are preserved if any file in the archive was not imported (e.g.
skipped as a duplicate, or the import was aborted), and in non-move import
modes.
- :doc:`plugins/fromfilename`: Support ``track`` prefix when parsing the track
number from the filename (e.g., ``track01.m4a``).
- **Tidal plugin**: Introduces a new plugin for fetching metadata from Tidal. It
supports album and track lookups by ID, including batch operations via
``albums_for_ids`` and ``tracks_for_ids``. It also enables search by query as
well as identifier-based retrieval, with support for ISRC codes (tracks) and
barcode/EANs (albums).

This is an initial, relatively minimal implementation, but already fully
usable for common metadata workflows. We welcome feedback, improvement ideas,
and community contributions to further extend its capabilities.

See :doc:`plugins/tidal` for more information.

- Add support for adding or modifying a subtitle (ID3 tag ``TIT3``) field

Bug fixes
~~~~~~~~~

- :ref:`import-cmd`: Multi-disc album detection now recognizes ``cassette``,
``digital media``, and ``vinyl`` as disc markers (e.g. ``vinyl 1``, ``12 vinyl
2``), in addition to the existing ``disc``, ``disk``, and ``cd`` markers.
- :ref:`import-cmd`: Tags with a zero distance penalty are no longer shown as
differences in the match display. Previously, custom ``distance_weights``
could cause fields with no actual mismatch to appear in the ``≠`` line.
- Library path migration now also handles manually edited database rows where
item or album-art paths were stored as SQLite ``TEXT`` values instead of
bytes, so upgrading to the portable-path storage format no longer fails for
those libraries. :bug:`6561`
- :ref:`import-cmd`: Fix duplicate album art files (e.g. ``cover.2.jpg``) being
created when re-importing albums with the :doc:`plugins/fetchart` plugin
enabled. Old album art is now properly removed when replacing duplicate albums
during import. :bug:`1264` :bug:`6205`
- :doc:`plugins/discogs`: Prevent duplicate featured artists in track artist
fields when the same artist is credited both in ``artists`` (for example with
``Feat.`` join text) and ``extraartists`` as ``Featuring``. :bug:`6166`
- :ref:`import-cmd`: Metadata source plugin ID lookups now correctly call each
plugin's own lookup method when running in parallel. :bug:`6583`
- Improve ``DBAccessError`` messages to help users diagnose database permission
issues more easily. The error message now mentions directory missing and file
permissions as potential causes. :bug:`1676`
- :doc:`plugins/lyrics`: Fix apostrophe handling in the ``musixmatch`` backend
slug. :bug:`4759`
- :ref:`import-cmd`: With ``original_date: yes``, album-level ``year``,
``month``, and ``day`` now use the original release date. :bug:`6577`
- :doc:`plugins/musicbrainz`: Correctly handle release dates where leading or
intermediate components are missing, e.g. 2008-??-02
- :doc:`plugins/badfiles`: Respect quiet mode (the ``--quiet`` flag or
``import.quiet: yes`` config) during import so the corrupt-file prompt is
suppressed in non-interactive imports. :bug:`4736`

..
For plugin developers
~~~~~~~~~~~~~~~~~~~~~

Other changes
~~~~~~~~~~~~~

- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API
requests and deduplicate repeated Spotify track IDs within a run.

2.10.0 (April 19, 2026)
-----------------------

New features
~~~~~~~~~~~~

- **Beets library is now made portable**: item and album-art paths are now
stored relative to the library root in the database while remaining absolute
in the rest of beets. Path queries continue matching both library-relative
paths and absolute paths under the currently configured music directory under
the new storage model. The existing paths in the database are migrated
automatically the first time you run any ``beet`` command after the update.
:bug:`133`

.. warning::

make sure you run ``beet version`` (or any other command) at least once
after upgrading to trigger the migration. Only then you can safely move
the library to a new location.

- :doc:`plugins/inline`: Add access to the ``album`` or ``item`` object as
``db_obj`` in inline fields.
- :doc:`plugins/discogs`: Import Discogs remixer, lyricist, composer, and
arranger credits into the multi-value ``remixers``, ``lyricists``,
``composers``, and ``arrangers`` fields. :bug:`6380`
- :doc:`plugins/lyrics`: Add ``keep_synced`` config option and ``--keep-synced``
CLI flag to skip re-fetching lyrics for tracks that already have synced
lyrics, even when ``force`` is enabled. :bug:`5249`
- :doc:`plugins/musicbrainz`: Use aliases for artist credit.
- Metadata source plugin searches and lookups are now executed concurrently,
speeding up lookups when multiple plugins (e.g. MusicBrainz and Spotify) are
enabled.

Bug fixes
~~~~~~~~~

- :ref:`import-cmd` Automatically remux WAV files containing MP3 streams
(``WAVE_FORMAT_MPEGLAYER3``) to proper MP3 files during import, instead of
silently importing them with incorrect metadata. :bug:`6455`
- :doc:`plugins/listenbrainz`: Retry listenbrainz requests for temporary
failures.
- :doc:`plugins/chroma`: Do not produce MusicBrainz-sourced autotagger
candidates when the :doc:`plugins/musicbrainz` plugin is not enabled. The
chroma plugin now looks up the musicbrainz plugin through the metadata-source
registry instead of unconditionally instantiating its own private instance,
which also restores compatibility with :doc:`plugins/mbpseudo` for
chroma-triggered lookups. :bug:`6212` :bug:`6441`
- :ref:`import-cmd` Remove clutter from imported album folders. :bug:`5016`
- :doc:`plugins/web`: Fix a stored XSS vulnerability where unescaped metadata
fields (artist, album, title, comments, lyrics) could execute arbitrary
JavaScript in the browser. Template tags now use ``<%-`` (escaped
interpolation) instead of ``<%=`` (raw interpolation).

For plugin developers
~~~~~~~~~~~~~~~~~~~~~

- Consumers of :py:class:`beetsplug._utils.musicbrainz.MusicBrainzAPI` now
receive normalized MusicBrainz payloads with underscore-separated field names
(for example ``artist_credit`` and ``release_group``) and grouped relation
lists such as ``work_relations``, ``release_relations``, and
``url_relations``. The API responses are also now fully typed with concrete
``TypedDict`` models for releases, recordings, works, and relations. Update
direct access to raw MusicBrainz response keys if needed.

..
Other changes
~~~~~~~~~~~~~

2.9.0 (April 11, 2026)
----------------------

Beets now officially supports Python 3.14.

New features
~~~~~~~~~~~~

- :ref:`import-cmd` Use ffprobe to recognize format of any import music file
that has no extension. If the file cannot be recognized as a music file, leave
it alone. :bug:`4881`
Expand All @@ -183,27 +34,16 @@ New features
``arranger`` fields. Existing libraries are migrated automatically, and
:doc:`plugins/musicbrainz` now preserves each MusicBrainz ``remixer``,
``lyricist``, ``composer``, and ``arranger`` relation as a separate value.
- :doc:`plugins/musicbrainz`: Store MBIDs for remixers, lyricists, composers,
and arrangers in the new multi-valued fields ``remixers_mbid``,
``lyricists_mbid``, ``composers_mbid``, and ``arrangers_mbid``. :bug:`5698`
:bug:`5698`
- :doc:`plugins/replaygain`: Conflicting replay gain tags are now removed on
write. RG_* tags are removed when setting R128_* and vice versa.
- :doc:`plugins/fetchart`: Add support for WebP images.
- :doc:`plugins/lastgenre`: Add support for a user-configurable ignorelist to
exclude unwanted or incorrect Last.fm (or existing) genres, either per artist
or globally :bug:`6449`

Bug fixes
~~~~~~~~~

- :doc:`plugins/deezer`: Fix Various Artists albums being tagged with a
localized string instead of the configured ``va_name``. Detection now uses
Deezer's artist ID rather than the artist name string. :bug:`4956`
- :doc:`plugins/listenbrainz`: Paginate through all ListenBrainz listens instead
of fetching only 25, aggregate individual listen events into correct play
counts, use ``recording_mbid`` from the ListenBrainz mapping when available,
and avoid per-listen MusicBrainz API lookups that caused imports to hang on
large listen histories. :bug:`6469`
- Correctly handle semicolon-delimited genre values from externally-tagged
files. :bug:`6450`
- :doc:`plugins/listenbrainz`: Fix ``lbimport`` crashing when ListenBrainz
Expand All @@ -227,12 +67,12 @@ Bug fixes
switch to the plural field names. :ref:`list-cmd`, and query expressions,
accept the same legacy singular field names and warn users to switch to the
plural field names. :bug:`6483`
- :doc:`plugins/fetchart`: Error when a configured source does not exist or
sources configuration is empty. :bug:`6336`
- :doc:`plugins/rewrite` :doc:`plugins/advancedrewrite`: Fix rewriting
multi-valued fields such as ``genres`` by applying rules to each matching list
entry. Additionally, apply rewrite rules in config order, so that multiple
rules can be applied to the same field. :bug:`6515`
- Improved error message when the database cannot be opened. When SQLite reports
an ``unable to open`` error, beets now suggests checking that the file or
parent directory is writable. The original SQLite error is preserved for
debugging. Also increased the default SQLite busy timeout from 5 s to 30 s to
reduce ``database is locked`` errors during concurrent access, and fixed the
``cannot not`` typo in the generic database error message. :bug:`1676`
Comment on lines +73 to +75
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grug see changelog say default sqlite busy timeout now 30s. but code change only Database init default, while Library pulls timeout from config (default still 5.0). so release note likely wrong unless you also bump config default / behavior.

Suggested change
debugging. Also increased the default SQLite busy timeout from 5 s to 30 s to
reduce ``database is locked`` errors during concurrent access, and fixed the
``cannot not`` typo in the generic database error message. :bug:`1676`
debugging, and the ``cannot not`` typo in the generic database error message
is fixed. :bug:`1676`

Copilot uses AI. Check for mistakes.

For plugin developers
~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -242,6 +82,10 @@ For plugin developers
respective multi-valued fields instead (``arrangers``, ``composers``,
``lyricists``, ``remixers``).

..
Other changes
~~~~~~~~~~~~~

2.8.0 (March 28, 2026)
----------------------

Expand Down
Loading
Loading