fix: cut long names from the middle, not the end
Build and publish container / build (pull_request) Canceled after 1m18s

The shortening fitted the path and destroyed its meaning. Lidarr writes
"Artist - Album - 07 - Flamethrower.mp3" inside a directory already named for
that artist and album, so a long album title occurs three times in one path and
everything that distinguishes one track from another sits at the very end.
Cutting from the end removed precisely that:

  King Gizzard & the Lizard Wizard - PetroDragonic Apocalypse; or, Dawn of Eter~c526.mp3

All seven tracks on that record reduced to the same string bar the hash. The
path fitted; the result was seven files nobody could tell apart on the device,
which is a worse outcome than the failure it replaced.

Cut from the middle instead, giving two thirds of the remaining room to the
tail because the head is generally a restatement of the directory the file
already sits in:

  King Gizzard & the Lizard~c526~ginning of Merciless Damnation - 07 - Flamethrower.mp3

The eight real paths that prompted this are now regression tests: every track
on that album keeps its number and title, all seven names stay distinct, and
The Beatles' "The Long One" -- whose length is the title itself rather than a
repeated album name -- keeps both ends.
This commit is contained in:
Emma Thorpe
2026-08-25 11:50:42 +01:00
parent d5f67c6de5
commit 46435feebd
3 changed files with 100 additions and 10 deletions
+20 -4
View File
@@ -246,10 +246,26 @@ from `--max-path` to get what a mirror-relative path may spend.
Over-budget paths are shortened from the **deepest component outward**: the Over-budget paths are shortened from the **deepest component outward**: the
track name carries the least navigational value and the artist directory the track name carries the least navigational value and the artist directory the
most, so the filename goes first and the artist is touched only if nothing else most, so the filename goes first and the artist is touched only if nothing else
will do. A shortened component keeps its extension and gains four hex digits of will do.
the original name — two long names sharing a prefix would otherwise cut to the
same string, and a silent collision between two tracks is worse than an ugly A component is cut **from the middle**, not the end, because of how these names
filename. are built. Lidarr writes `Artist - Album - 07 - Flamethrower.mp3` inside a
directory already named for that artist and album, so a long album title
appears three times in one path and the informative part — the track number and
title — is at the very end. Cutting from the end throws exactly that away:
```
before King Gizzard & the Lizard Wizard - PetroDragonic Apocalypse; or, Dawn of Eternal
Night - An Annihilation of Planet Earth and the Beginning of Merciless
Damnation - 07 - Flamethrower.mp3
after King Gizzard & the Lizard~c526~ginning of Merciless Damnation - 07 - Flamethrower.mp3
```
Two thirds of the remaining room goes to the tail, since the head is usually a
restatement of the directory it sits in. A shortened component gains four hex
digits of the original name: two names sharing both a head and a tail would
otherwise produce the same string, and a silent collision between two tracks is
worse than an ugly filename.
The result is stable: the same source always produces the same shortened name, The result is stable: the same source always produces the same shortened name,
so a pass does not rename what the previous pass wrote. A path too deeply so a pass does not rename what the previous pass wrote. A path too deeply
+21 -6
View File
@@ -168,20 +168,35 @@ def fat32_safe(component):
def shorten_component(component, budget): def shorten_component(component, budget):
"""Return a component of at most `budget` characters, marked as shortened. """Return a component of at most `budget` characters, cut from the middle.
The mark is four hex digits of the original name. Two different long names From the middle, not the end, because of how these names are built. Lidarr
would otherwise cut down to the same string, and a silent collision between writes "Artist - Album - 07 - Flamethrower.mp3" inside a directory already
two tracks is worse than an ugly filename. named for that artist and album, so the informative part -- the track
number and title -- is at the very end. Cutting from the end discards it
and leaves every track on the record with the same name.
The four hex digits are of the original component. Two names sharing both a
head and a tail would otherwise produce the same string, and a silent
collision between two tracks is worse than an ugly filename.
""" """
stem, dot, extension = component.rpartition(".") stem, dot, extension = component.rpartition(".")
if not dot or len(extension) > 4: if not dot or len(extension) > 4:
stem, extension = component, "" stem, extension = component, ""
else: else:
extension = dot + extension extension = dot + extension
digest = hashlib.blake2s(component.encode("utf-8"), digest_size=2).hexdigest() digest = hashlib.blake2s(component.encode("utf-8"), digest_size=2).hexdigest()
tail = f"~{digest}{extension}" marker = f"~{digest}~"
return stem[: max(1, budget - len(tail))].rstrip(". ") + tail room = max(2, budget - len(extension) - len(marker))
if room >= len(stem):
return stem + extension
# Two thirds to the tail: the head is usually a restatement of the
# directory it sits in, and the tail is what tells two tracks apart.
keep_end = min(len(stem), room * 2 // 3)
keep_start = max(1, room - keep_end)
return stem[:keep_start].rstrip(". ") + marker + stem[len(stem) - keep_end :] + extension
def fit_path(relative, budget): def fit_path(relative, budget):
+59
View File
@@ -705,3 +705,62 @@ def test_a_path_that_cannot_be_made_to_fit_is_reported(tmp_path, make_flac, capl
run(source, mirror, "--fat32-safe", "--max-path", "80") run(source, mirror, "--fat32-safe", "--max-path", "80")
assert "too" in caplog.text and "nested" in caplog.text assert "too" in caplog.text and "nested" in caplog.text
# Lidarr writes "Artist - Album - NN - Title.mp3" inside a directory already
# named for that artist and album, so a long album title appears three times in
# one path. These are real paths from a real library.
GIZZARD_ALBUM = (
"PetroDragonic Apocalypse; or, Dawn of Eternal Night - An Annihilation of"
" Planet Earth and the Beginning of Merciless Damnation"
)
GIZZARD_TRACKS = (
"01 - Motor Spirit",
"02 - Supercell",
"03 - Converge",
"04 - Witchcraft",
"05 - Gila Monster",
"06 - Dragon",
"07 - Flamethrower",
)
def gizzard_path(track):
return Path(
"King Gizzard & the Lizard Wizard",
f"{GIZZARD_ALBUM} (2023)",
f"King Gizzard & the Lizard Wizard - {GIZZARD_ALBUM} - {track}.mp3",
)
def test_shortening_keeps_the_part_that_tells_tracks_apart():
"""Cutting from the end discards the track number and title, which is all
that distinguishes one track on the record from another."""
for track in GIZZARD_TRACKS:
fitted = music_mirror.fit_path(gizzard_path(track), 253)
assert len(str(fitted)) <= 253
assert fitted.name.endswith(f"{track}.mp3"), fitted.name
def test_every_track_on_a_long_album_keeps_a_distinct_name():
fitted = {music_mirror.fit_path(gizzard_path(t), 253).name for t in GIZZARD_TRACKS}
assert len(fitted) == len(GIZZARD_TRACKS)
def test_a_long_title_keeps_both_ends():
"""The Beatles' 'The Long One' is one track whose title is the long part."""
path = Path(
"The Beatles",
"Abbey Road (1969)",
"Digital Media 03",
"The Beatles - Abbey Road - 09 - The Long One - You Never Give Me Your Money"
" + Sun King + Mean Mr Mustard + Her Majesty + Polythene Pam + She Came In"
" Through the Bathroom Window+ Golden Slumbers + Carry That Weight + The End.mp3",
)
fitted = music_mirror.fit_path(path, 253)
assert len(str(fitted)) <= 253
assert fitted.name.startswith("The Beatles - Abbey Road - 09 - The Long One")
assert fitted.name.endswith("The End.mp3")