Repository: beetbox/beets

Issue:
beets 2.3 doesn't like dots in the filename patterns
<!--
Describe your problem, feature request, or discussion topic here.

Please fill out this and the "Setup" section below and remember to include
enough detail so that other people can reproduce the problem.
-->

### Problem

Running `beet import` on any album with
```yaml
paths:
    default: $albumartist/$album%aunique{}/$track. $title
```
in the configuration results in filenames like `01.flac`, i.e. everything after the dot is missing.

### Investigation

Looking for "path" in the git log brings up the very recently merged #5224, which introduced `os.path.splitext` in https://github.com/beetbox/beets/blob/master/beets/util/__init__.py#L767 and adding some `print`s for `path`, `first_stage` and `second_stage` shows what happens when you also change the configuration to
```yaml
paths:
    default: $albumartist/$album%aunique{}/$track. $title.x.y
```
`path` is the filename *without* extension, `first_stage` removes the `.y`, and `second_stage` the `.x`.

### Solutions?

Not sure. The above config is a workaround, adding a `+suffix` to the path passed to the three `_legalize_stage` calls kinda works but shows
```
Fell back to default replacements when naming file Artist/Album/01. Title.x.y. Configure replacements to avoid lengthening the filename.
```
Anyway, something in there expects a filename containing a dot to have an extension, but the filename used doesn't have the extension added.


Discussion:
Further to this I have the following defined in replace and still get the above warning

```yaml
replace:
  '^\.\.\.': "…"
  '^\.\.': "‥"
  '^\.': "․"
  '\.\.\.': "…"
  '\.\.\.$': "…"
  '\.\.$': "‥"
  '\.\.': "‥"
  '\.$': "․"
```

Single `U+002E` at start or end is replaced with `U+2024`
Apologies, my bad! I will look at this later tonight.



Patch:
diff --git a/beets/library.py b/beets/library.py
index 1fe253c189..8fd1c80220 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -349,6 +349,7 @@ class LibModel(dbcore.Model["Library"]):
 
     # Config key that specifies how an instance should be formatted.
     _format_config_key: str
+    path: bytes
 
     @cached_classproperty
     def writable_media_fields(cls) -> set[str]:
@@ -644,7 +645,7 @@ class Item(LibModel):
     _format_config_key = "format_item"
 
     # Cached album object. Read-only.
-    __album = None
+    __album: Album | None = None
 
     @cached_classproperty
     def _relation(cls) -> type[Album]:
@@ -663,9 +664,9 @@ def relation_join(cls) -> str:
         )
 
     @property
-    def filepath(self) -> Path | None:
+    def filepath(self) -> Path:
         """The path to the item's file as pathlib.Path."""
-        return Path(os.fsdecode(self.path)) if self.path else self.path
+        return Path(os.fsdecode(self.path))
 
     @property
     def _cached_album(self):
@@ -1126,7 +1127,7 @@ def destination(
             )
 
         lib_path_str, fallback = util.legalize_path(
-            subpath, db.replacements, os.path.splitext(self.path)[1]
+            subpath, db.replacements, self.filepath.suffix
         )
         if fallback:
             # Print an error message if legalization fell back to
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index 6d0ce0f88f..e17de1f51f 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -715,7 +715,7 @@ def truncate_path(str_path: str) -> str:
     path = Path(str_path)
     parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]]
     stem = truncate_str(path.stem, max_length - len(path.suffix))
-    return str(Path(*parent_parts, stem).with_suffix(path.suffix))
+    return str(Path(*parent_parts, stem)) + path.suffix
 
 
 def _legalize_stage(
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9dd1297129..ab0b9519d1 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -10,6 +10,11 @@ New features:
 
 Bug fixes:
 
+* :doc:`/reference/pathformat`: Fixed a regression where path legalization
+  incorrectly removed parts of user-configured path formats that followed a dot
+  (**.**).
+  :bug:`5771`
+
 For packagers:
 
 Other changes:


Test patch:
diff --git a/test/test_util.py b/test/test_util.py
index 3a5e55c492..6b795b957e 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -171,6 +171,8 @@ def test_bytesting_path_windows_removes_magic_prefix(self):
 
 
 class TestPathLegalization:
+    _p = pytest.param
+
     @pytest.fixture(autouse=True)
     def _patch_max_filename_length(self, monkeypatch):
         monkeypatch.setattr("beets.util.get_max_filename_length", lambda: 5)
@@ -178,20 +180,22 @@ def _patch_max_filename_length(self, monkeypatch):
     @pytest.mark.parametrize(
         "path, expected",
         [
-            ("abcdeX/fgh", "abcde/fgh"),
-            ("abcde/fXX.ext", "abcde/f.ext"),
-            ("a🎹/a.ext", "a🎹/a.ext"),
-            ("ab🎹/a.ext", "ab/a.ext"),
+            _p("abcdeX/fgh", "abcde/fgh", id="truncate-parent-dir"),
+            _p("abcde/fXX.ext", "abcde/f.ext", id="truncate-filename"),
+            # note that 🎹 is 4 bytes long:
+            # >>> "🎹".encode("utf-8")
+            # b'\xf0\x9f\x8e\xb9'
+            _p("a🎹/a.ext", "a🎹/a.ext", id="unicode-fit"),
+            _p("ab🎹/a.ext", "ab/a.ext", id="unicode-truncate-fully-one-byte-over-limit"),
+            _p("f.a.e", "f.a.e", id="persist-dot-in-filename"),  # see #5771
         ],
-    )
+    )  # fmt: skip
     def test_truncate(self, path, expected):
         path = path.replace("/", os.path.sep)
         expected = expected.replace("/", os.path.sep)
 
         assert util.truncate_path(path) == expected
 
-    _p = pytest.param
-
     @pytest.mark.parametrize(
         "replacements, expected_path, expected_truncated",
         [  # [ repl before truncation, repl after truncation   ]