Skip to content

Commit 0379f68

Browse files
authored
Do not remove everything that follows a dot in item destination path (#5774)
This PR addresses an issue where path legalization, specifically the `truncate_path` function, incorrectly removed parts of filenames that followed a dot. This occurred because `pathlib.Path.with_suffix` was used, which replaces the existing suffix (or what it considers a suffix) rather than just appending. The fix modifies `truncate_path` to manually append the original suffix after truncating the filename stem. This ensures that dots within the filename, not part of the actual extension, are preserved. Fixes #5771.
2 parents 6772042 + a4dabb6 commit 0379f68

File tree

4 files changed

+22
-12
lines changed

4 files changed

+22
-12
lines changed

beets/library.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ class LibModel(dbcore.Model["Library"]):
349349

350350
# Config key that specifies how an instance should be formatted.
351351
_format_config_key: str
352+
path: bytes
352353

353354
@cached_classproperty
354355
def writable_media_fields(cls) -> set[str]:
@@ -644,7 +645,7 @@ class Item(LibModel):
644645
_format_config_key = "format_item"
645646

646647
# Cached album object. Read-only.
647-
__album = None
648+
__album: Album | None = None
648649

649650
@cached_classproperty
650651
def _relation(cls) -> type[Album]:
@@ -663,9 +664,9 @@ def relation_join(cls) -> str:
663664
)
664665

665666
@property
666-
def filepath(self) -> Path | None:
667+
def filepath(self) -> Path:
667668
"""The path to the item's file as pathlib.Path."""
668-
return Path(os.fsdecode(self.path)) if self.path else self.path
669+
return Path(os.fsdecode(self.path))
669670

670671
@property
671672
def _cached_album(self):
@@ -1126,7 +1127,7 @@ def destination(
11261127
)
11271128

11281129
lib_path_str, fallback = util.legalize_path(
1129-
subpath, db.replacements, os.path.splitext(self.path)[1]
1130+
subpath, db.replacements, self.filepath.suffix
11301131
)
11311132
if fallback:
11321133
# Print an error message if legalization fell back to

beets/util/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ def truncate_path(str_path: str) -> str:
715715
path = Path(str_path)
716716
parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]]
717717
stem = truncate_str(path.stem, max_length - len(path.suffix))
718-
return str(Path(*parent_parts, stem).with_suffix(path.suffix))
718+
return str(Path(*parent_parts, stem)) + path.suffix
719719

720720

721721
def _legalize_stage(

docs/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ New features:
1010

1111
Bug fixes:
1212

13+
* :doc:`/reference/pathformat`: Fixed a regression where path legalization
14+
incorrectly removed parts of user-configured path formats that followed a dot
15+
(**.**).
16+
:bug:`5771`
17+
1318
For packagers:
1419

1520
Other changes:

test/test_util.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,27 +171,31 @@ def test_bytesting_path_windows_removes_magic_prefix(self):
171171

172172

173173
class TestPathLegalization:
174+
_p = pytest.param
175+
174176
@pytest.fixture(autouse=True)
175177
def _patch_max_filename_length(self, monkeypatch):
176178
monkeypatch.setattr("beets.util.get_max_filename_length", lambda: 5)
177179

178180
@pytest.mark.parametrize(
179181
"path, expected",
180182
[
181-
("abcdeX/fgh", "abcde/fgh"),
182-
("abcde/fXX.ext", "abcde/f.ext"),
183-
("a🎹/a.ext", "a🎹/a.ext"),
184-
("ab🎹/a.ext", "ab/a.ext"),
183+
_p("abcdeX/fgh", "abcde/fgh", id="truncate-parent-dir"),
184+
_p("abcde/fXX.ext", "abcde/f.ext", id="truncate-filename"),
185+
# note that 🎹 is 4 bytes long:
186+
# >>> "🎹".encode("utf-8")
187+
# b'\xf0\x9f\x8e\xb9'
188+
_p("a🎹/a.ext", "a🎹/a.ext", id="unicode-fit"),
189+
_p("ab🎹/a.ext", "ab/a.ext", id="unicode-truncate-fully-one-byte-over-limit"),
190+
_p("f.a.e", "f.a.e", id="persist-dot-in-filename"), # see #5771
185191
],
186-
)
192+
) # fmt: skip
187193
def test_truncate(self, path, expected):
188194
path = path.replace("/", os.path.sep)
189195
expected = expected.replace("/", os.path.sep)
190196

191197
assert util.truncate_path(path) == expected
192198

193-
_p = pytest.param
194-
195199
@pytest.mark.parametrize(
196200
"replacements, expected_path, expected_truncated",
197201
[ # [ repl before truncation, repl after truncation ]

0 commit comments

Comments
 (0)