diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index ca60f50caa..d1e9da20d4 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1115,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 627b079817..1d51a4cdf1 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -891,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: {}", diff --git a/docs/changelog.rst b/docs/changelog.rst index 85b7523a52..dc515e7857 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -42,6 +42,12 @@ Bug fixes - :ref:`import-cmd` Autotagging by explicit release or recording IDs now keeps candidates from all enabled metadata sources instead of dropping matches when different providers share the same ID. :bug:`6178` :bug:`6181` +- Improved error message when database cannot be opened. When SQLite fails to + open the database with 'unable to open' error, beets now provides a helpful + message suggesting it may be a permissions issue and recommends checking that + the file or directory is writable. The original SQLite error is included for + debugging. Also fixed typo in error message ('cannot not' to 'could not'). + :bug:`1676` - :doc:`plugins/mbsync` and :doc:`plugins/missing` now use each item's stored ``data_source`` for ID lookups, with a fallback to ``MusicBrainz``. - :doc:`plugins/musicbrainz`: Use ``va_name`` config for ``albumartist_sort``, diff --git a/pyproject.toml b/pyproject.toml index 89abb6c7ed..7f92534765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,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())