diff --git a/README.md b/README.md index 4f55c96..dd9ba4f 100644 --- a/README.md +++ b/README.md @@ -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 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 -will do. A shortened component keeps its extension and gains four hex digits of -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 -filename. +will do. + +A component is cut **from the middle**, not the end, because of how these names +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, so a pass does not rename what the previous pass wrote. A path too deeply diff --git a/music_mirror.py b/music_mirror.py index a4f5738..86cf13d 100644 --- a/music_mirror.py +++ b/music_mirror.py @@ -168,20 +168,35 @@ def fat32_safe(component): 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 - would otherwise cut down to the same string, and a silent collision between - two tracks is worse than an ugly filename. + From the middle, not the end, because of how these names are built. Lidarr + writes "Artist - Album - 07 - Flamethrower.mp3" inside a directory already + 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(".") if not dot or len(extension) > 4: stem, extension = component, "" else: extension = dot + extension + digest = hashlib.blake2s(component.encode("utf-8"), digest_size=2).hexdigest() - tail = f"~{digest}{extension}" - return stem[: max(1, budget - len(tail))].rstrip(". ") + tail + marker = f"~{digest}~" + 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): diff --git a/tests/test_music_mirror.py b/tests/test_music_mirror.py index 097bfd8..0f0a4d7 100644 --- a/tests/test_music_mirror.py +++ b/tests/test_music_mirror.py @@ -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") 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")