diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 140f1d4d0a..d1e9da20d4 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -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. """ @@ -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 @@ -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 @cached_classproperty @@ -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" diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index e1c56fed07..1d51a4cdf1 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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. @@ -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 @@ -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: + # 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: {}", @@ -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. """ @@ -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. """ diff --git a/docs/changelog.rst b/docs/changelog.rst index 34338cef43..f0d40cba9d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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` @@ -183,15 +34,9 @@ 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 ~~~~~~~~~ @@ -199,11 +44,6 @@ 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 @@ -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` For plugin developers ~~~~~~~~~~~~~~~~~~~~~ @@ -242,6 +82,10 @@ For plugin developers respective multi-valued fields instead (``arrangers``, ``composers``, ``lyricists``, ``remixers``). +.. + Other changes + ~~~~~~~~~~~~~ + 2.8.0 (March 28, 2026) ---------------------- diff --git a/pyproject.toml b/pyproject.toml index c34b8cc41c..b7f876ca80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "beets" -version = "2.10.0" +version = "2.8.0" description = "music tagger and library organizer" authors = ["Adrian Sampson "] maintainers = ["Serene-Arc"] @@ -21,7 +21,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] packages = [ @@ -42,16 +41,16 @@ Changelog = "https://github.com/beetbox/beets/blob/master/docs/changelog.rst" "Bug Tracker" = "https://github.com/beetbox/beets/issues" [tool.poetry.dependencies] -python = ">=3.10,<3.15" +python = ">=3.10,<4" colorama = { version = "*", markers = "sys_platform == 'win32'" } confuse = ">=2.2.0" jellyfish = "*" lap = ">=0.5.12" -mediafile = ">=0.16.2" +mediafile = ">=0.16.0" numpy = [ { python = "<3.13", version = ">=2.0.2" }, - { python = ">=3.13", version = ">=2.3.5" }, + { python = ">=3.13", version = ">=2.3.4" }, ] packaging = ">=24.0" platformdirs = ">=3.5.0" @@ -73,12 +72,12 @@ scipy = [ # for librosa ] numba = [ # for librosa { python = "<3.13", version = ">=0.60", optional = true }, - { python = ">=3.13", version = ">=0.63.1", optional = true }, + { python = ">=3.13", version = ">=0.62.1", optional = true }, ] mutagen = { version = ">=1.33", optional = true } Pillow = { version = "*", optional = true } py7zr = { version = "*", optional = true } -pyacoustid = { version = "^1.3.1", optional = true } +pyacoustid = { version = "*", optional = true } PyGObject = { version = "*", optional = true } pylast = { version = "*", optional = true } python-mpd2 = { version = ">=0.4.2", optional = true } @@ -92,7 +91,7 @@ soco = { version = "*", optional = true } docutils = { version = ">=0.20.1", optional = true } pydata-sphinx-theme = { version = "*", optional = true } -sphinx = { version = "<9", optional = true } +sphinx = { version = "*", optional = true } sphinx-design = { version = ">=0.6.1", optional = true } sphinx-copybutton = { version = ">=0.5.2", optional = true } sphinx-toolbox = { version = ">=4.1.0", optional = true } @@ -106,7 +105,6 @@ langdetect = "*" pylast = "*" pytest = "*" pytest-cov = "*" -pytest-factoryboy = ">=2.8.1" pytest-flask = "*" python-mpd2 = "*" python3-discogs-client = ">=2.3.15" @@ -270,7 +268,8 @@ ref = "test" # measure coverage across logical branches # show which tests cover specific lines in the code (see the HTML report) env.OPTS = """ ---cov=. +--cov=beets +--cov=beetsplug --cov-report=xml:.reports/coverage.xml --cov-report=html:.reports/html --cov-branch @@ -330,6 +329,7 @@ ignore = [ "beets/**" = ["PT"] "test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] +"test/ui/test_ui_init.py" = ["PT"] "test/util/test_diff.py" = ["E501"] "test/util/test_id_extractors.py" = ["E501"] "test/**" = ["RUF001"] # we use Unicode characters in tests diff --git a/test/ui/test_ui_init.py b/test/ui/test_ui_init.py index 00e0a6fe5d..2aa83e35b1 100644 --- a/test/ui/test_ui_init.py +++ b/test/ui/test_ui_init.py @@ -16,11 +16,13 @@ import os import shutil +import sqlite3 import unittest from copy import deepcopy from random import random +from unittest import mock -from beets import config, ui +from beets import config, library, ui from beets.test import _common from beets.test.helper import BeetsTestCase, IOMixin @@ -119,3 +121,54 @@ def test_create_no(self): if lib: lib._close() raise OSError("Parent directories should not be created.") + + +class DatabaseErrorTest(BeetsTestCase): + """Test database error handling with improved error messages.""" + + def test_database_error_with_unable_to_open(self): + """Test error message when database fails with 'unable to open' error.""" + test_config = deepcopy(config) + test_config["library"] = _common.os.fsdecode( + os.path.join(self.temp_dir, b"test.db") + ) + + # Mock Library to raise OperationalError with "unable to open" + with mock.patch.object( + library, + "Library", + side_effect=sqlite3.OperationalError( + "unable to open database file" + ), + ): + with self.assertRaises(ui.UserError) as cm: + ui._open_library(test_config) + + error_message = str(cm.exception) + # Should mention permissions and directory + self.assertIn("directory", error_message.lower()) + self.assertIn("writable", error_message.lower()) + self.assertIn("permissions", error_message.lower()) + + def test_database_error_fallback(self): + """Test fallback error message for other database errors.""" + test_config = deepcopy(config) + test_config["library"] = _common.os.fsdecode( + os.path.join(self.temp_dir, b"test.db") + ) + + # Mock Library to raise a different OperationalError + with mock.patch.object( + library, + "Library", + side_effect=sqlite3.OperationalError("disk I/O error"), + ): + with self.assertRaises(ui.UserError) as cm: + ui._open_library(test_config) + + error_message = str(cm.exception) + # Should contain the error but not the permissions message + self.assertIn("could not be opened", error_message) + self.assertIn("disk I/O error", error_message) + # Should NOT have the permissions-related message + self.assertNotIn("permissions", error_message.lower())