From b03b5c3830a001000d4405236803d8efe6fa07c7 Mon Sep 17 00:00:00 2001 From: lawrence3699 Date: Fri, 10 Apr 2026 11:51:05 +1000 Subject: [PATCH] fetchart: handle FilesystemError when setting album art When set_art encounters a permissions error or cross-device move failure (e.g. file locked by foobar2000 on Windows), the unhandled FilesystemError crashes the import or the fetchart CLI command. Wrap both _set_art call sites in try/except FilesystemError, matching the pattern already used in the cleanup method (line ~520). On failure, log a warning and continue instead of aborting. Fixes #6193. --- beetsplug/fetchart.py | 15 +++++++++++--- test/plugins/test_fetchart.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 2f4b4354c0..c6b9972ed5 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1525,7 +1525,11 @@ def assign_art(self, session: ImportSession, task: ImportTask): candidate = self.art_candidates.pop(task) removal_enabled = self._is_source_file_removal_enabled() - self._set_art(task.album, candidate, not removal_enabled) + try: + self._set_art(task.album, candidate, not removal_enabled) + except util.FilesystemError as exc: + self._log.warning("error setting art: {}", exc) + return # No art was set, so skip the prune step below. if removal_enabled and not self._is_candidate_fallback(candidate): task.prune(candidate.path) @@ -1629,8 +1633,13 @@ def batch_fetch_art( candidate = self.art_for_album(album, local_paths) if candidate: - self._set_art(album, candidate) - message = colorize("text_success", "found album art") + try: + self._set_art(album, candidate) + except util.FilesystemError as exc: + self._log.warning("error setting art: {}", exc) + message = colorize("text_error", "error setting art") + else: + message = colorize("text_success", "found album art") else: message = colorize("text_error", "no art found") ui.print_(f"{album}: {message}") diff --git a/test/plugins/test_fetchart.py b/test/plugins/test_fetchart.py index e1a0cbd9b5..98fe98c328 100644 --- a/test/plugins/test_fetchart.py +++ b/test/plugins/test_fetchart.py @@ -16,6 +16,7 @@ import ctypes import os import sys +from unittest.mock import patch from beets import util from beets.test.helper import IOMixin, PluginTestCase @@ -119,6 +120,43 @@ def test_colorization(self): out = self.run_with_output("fetchart") assert " - the älbum: \x1b[1;31mno art found\x1b[39;49;00m\n" == out + def test_batch_fetch_filesystem_error(self): + """When _set_art raises FilesystemError (e.g. permissions), the + fetchart command should log a warning instead of crashing.""" + self.touch(b"c\xc3\xb6ver.jpg", dir=self.album.path, content="IMAGE") + + exc = util.FilesystemError( + reason=PermissionError("mocked permission error"), + verb="move", + paths=[b"/src", b"/dst"], + ) + with patch( + "beetsplug.fetchart.FetchArtPlugin._set_art", + side_effect=exc, + ): + out = self.run_with_output("fetchart") + + assert "error setting art" in out + + def test_assign_art_filesystem_error(self): + """When _set_art raises FilesystemError during import, the import + should continue instead of crashing.""" + exc = util.FilesystemError( + reason=PermissionError("mocked permission error"), + verb="move", + paths=[b"/src", b"/dst"], + ) + fa = FetchArtPlugin() + # Simulate an import task with a queued candidate + task = type("FakeTask", (), {"album": self.album})() + fa.art_candidates[task] = type( + "FakeCandidate", (), {"path": b"/tmp/art.jpg", "source_name": "test"} + )() + + with patch.object(fa, "_set_art", side_effect=exc): + # Should not raise + fa.assign_art(session=None, task=task) + def test_sources_is_a_string(self): self.config["fetchart"].set({"sources": "filesystem"}) fa = FetchArtPlugin()