soulsync/tests/imports/test_import_paths.py
BoulderBadgeDad 3b49ac8280 Fix #767: Library Organizer dry run no longer creates folders
The reorganize preview (dry run) was physically creating destination album
folders, littering the library with empty dirs and making "changes" before the
user ever hit Apply.

Cause: preview_album_reorganize calls build_final_path_for_track purely to
COMPUTE the destination path string — but that shared helper has 9 os.makedirs
side effects (it's also the live download/import path builder, where creating
the dir is correct). So computing the preview path created "Lenka (Expanded
Edition)/" on disk.

Fix: build_final_path_for_track gains create_dirs=True; all 9 makedirs now route
through a gated helper. The reorganize PREVIEW passes create_dirs=False, so a
dry run computes the exact destination path with zero filesystem side effects.
Everything else keeps the default True:
- the download/import post-process flow (still writes files into the dir),
- retag,
- the reorganize APPLY path — verified it goes through post_process_fn (the real
  pipeline → build_final_path_for_track with create_dirs=True), so live moves
  still create their destination dirs. The gate only silences the dry run.

Tests: tests/imports/test_import_paths.py — create_dirs=False computes the
correct path (matching the reported "01 - The Show.flac") but writes NOTHING to
disk (not even the Transfer root); create_dirs=True still creates folders; both
yield an identical path. Updated two reorganize-orchestrator test doubles to
accept the new kwarg. 148 reorganize/paths/retag/pipeline tests pass.

Does NOT fix the second half of #767 (Expanded Edition picked over the standard
album). That is NOT a reorganizer bug: the library album row was linked to the
deluxe release at enrichment time (its stored spotify_album_id/itunes_album_id/
deezer_id points at "Lenka (Expanded Edition)"), and the reorganizer faithfully
reorganizes to whatever the album is linked to. The real fix is in album
enrichment's edition preference — tracked separately.
2026-06-02 10:32:06 -07:00

605 lines
22 KiB
Python

import os
import core.imports.album_naming as album_naming
import core.imports.paths as import_paths
class _Config:
def __init__(self, values):
self._values = values
def get(self, key, default=None):
return self._values.get(key, default)
def test_sanitize_filename_replaces_illegal_characters():
assert import_paths.sanitize_filename("AC/DC: Song?") == "AC_DC_ Song_"
assert import_paths.sanitize_filename("AUX.txt").startswith("_")
# ── #767: dry-run path build must not create folders ──────────────────────
def _album_path_config(tmp_path):
return _Config({
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$albumartist - $album/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
})
def _album_context():
return {
"artist": {"name": "Lenka"},
"album": {"name": "Lenka", "id": "album-1", "release_date": "2008-01-01",
"total_tracks": 12, "album_type": "album", "artists": [{"name": "Lenka"}]},
"track_info": {"name": "The Show", "id": "t1", "track_number": 1,
"disc_number": 1, "artists": [{"name": "Lenka"}]},
"original_search_result": {"title": "The Show", "clean_title": "The Show",
"clean_album": "Lenka", "clean_artist": "Lenka",
"artists": [{"name": "Lenka"}]},
"source": "deezer", "is_album_download": False,
}
def test_create_dirs_false_does_not_create_folders(monkeypatch, tmp_path):
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _album_path_config(tmp_path))
monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None)
final_path, created = import_paths.build_final_path_for_track(
_album_context(), {"name": "Lenka"},
{"is_album": True, "album_name": "Lenka", "track_number": 1, "disc_number": 1},
".flac", create_dirs=False,
)
# Path is still computed correctly...
assert created is True
assert final_path == str(tmp_path / "Transfer" / "Lenka" / "Lenka - Lenka" / "01 - The Show.flac")
# ...but NOTHING was written to disk — not even the Transfer root.
assert not (tmp_path / "Transfer").exists()
def test_create_dirs_true_still_creates_folders(monkeypatch, tmp_path):
# The download/import flow must keep working (default behavior unchanged).
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _album_path_config(tmp_path))
monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None)
final_path, created = import_paths.build_final_path_for_track(
_album_context(), {"name": "Lenka"},
{"is_album": True, "album_name": "Lenka", "track_number": 1, "disc_number": 1},
".flac", # create_dirs defaults True
)
assert created is True
assert (tmp_path / "Transfer" / "Lenka" / "Lenka - Lenka").is_dir()
def test_create_dirs_false_and_true_yield_identical_path(monkeypatch, tmp_path):
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _album_path_config(tmp_path))
monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None)
dry, _ = import_paths.build_final_path_for_track(
_album_context(), {"name": "Lenka"},
{"is_album": True, "album_name": "Lenka", "track_number": 1, "disc_number": 1},
".flac", create_dirs=False,
)
live, _ = import_paths.build_final_path_for_track(
_album_context(), {"name": "Lenka"},
{"is_album": True, "album_name": "Lenka", "track_number": 1, "disc_number": 1},
".flac", create_dirs=True,
)
assert dry == live
def test_build_simple_download_destination_uses_album_folder(monkeypatch, tmp_path):
config = _Config({"soulseek.transfer_path": str(tmp_path / "Transfer")})
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
destination, album_name, filename = import_paths.build_simple_download_destination(
{
"search_result": {
"filename": "Album Folder/source.flac",
"album": "Album Folder",
}
},
str(tmp_path / "source.flac"),
)
assert destination == tmp_path / "Transfer" / "Album Folder" / "source.flac"
assert album_name == "Album Folder"
assert filename == "source.flac"
assert destination.parent.exists()
def test_build_simple_download_destination_falls_back_to_transfer_root(monkeypatch, tmp_path):
config = _Config({"soulseek.transfer_path": str(tmp_path / "Transfer")})
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
destination, album_name, filename = import_paths.build_simple_download_destination(
{
"search_result": {
"filename": "source.flac",
"album": "Unknown Album",
}
},
str(tmp_path / "source.flac"),
)
assert destination == tmp_path / "Transfer" / "source.flac"
assert album_name == ""
assert filename == "source.flac"
assert destination.parent.exists()
def test_get_file_path_from_template_raw_handles_quality_and_disc_placeholders(monkeypatch):
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Config({}))
folder_path, filename = import_paths.get_file_path_from_template_raw(
"$artist/$album/$discnum - $title [$quality]",
{
"artist": "Artist One",
"album": "Album One",
"title": "Song One",
"quality": "FLAC 16bit",
"disc_number": 3,
},
)
assert folder_path == os.path.join("Artist One", "Album One")
assert filename == "3 - Song One [FLAC 16bit]"
def test_get_file_path_from_template_raw_substitutes_cdnum_for_multi_disc(monkeypatch):
"""$cdnum should expand to 'CDxx' on multi-disc albums (regression).
Reported by user: filenames had literal '$cdnum' instead of 'CD02'
because `_replace_template_variables` did not handle the placeholder.
"""
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Config({}))
folder_path, filename = import_paths.get_file_path_from_template_raw(
"$artist/$album/$cdnum - $track - $title",
{
"artist": "Artist",
"album": "Album",
"title": "Song",
"track_number": 5,
"disc_number": 2,
"total_discs": 2,
},
)
assert folder_path == os.path.join("Artist", "Album")
assert filename == "CD02 - 05 - Song"
def test_get_file_path_from_template_raw_collapses_cdnum_for_single_disc(monkeypatch):
"""$cdnum should expand to '' on single-disc albums so it disappears cleanly."""
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Config({}))
folder_path, filename = import_paths.get_file_path_from_template_raw(
"$artist/$album/$cdnum - $track - $title",
{
"artist": "Artist",
"album": "Album",
"title": "Song",
"track_number": 5,
"disc_number": 1,
"total_discs": 1,
},
)
# No "CD01" prefix; trailing-dash regex collapses the empty placeholder.
assert folder_path == os.path.join("Artist", "Album")
assert filename == "05 - Song"
def test_get_file_path_from_template_raw_strips_cdnum_from_folders(monkeypatch):
"""Even if user puts $cdnum inside a folder segment, it gets stripped (defensive)."""
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Config({}))
folder_path, filename = import_paths.get_file_path_from_template_raw(
"$artist/$album/$cdnum/$track - $title",
{
"artist": "Artist",
"album": "Album",
"title": "Song",
"track_number": 5,
"disc_number": 1,
"total_discs": 1,
},
)
# Folder containing only $cdnum (which expands to empty for single-disc) gets dropped.
assert folder_path == os.path.join("Artist", "Album")
assert filename == "05 - Song"
def test_resolve_album_group_upgrades_standard_to_deluxe():
album_naming.clear_album_grouping_cache()
artist_context = {"name": "Cache Artist"}
standard_album = {"album_name": "Cache Album"}
deluxe_album = {"album_name": "Cache Album (Deluxe Edition)"}
assert import_paths.resolve_album_group(artist_context, standard_album) == "Cache Album"
assert import_paths.resolve_album_group(artist_context, deluxe_album) == "Cache Album (Deluxe Edition)"
assert import_paths.resolve_album_group(artist_context, standard_album) == "Cache Album (Deluxe Edition)"
def test_build_final_path_for_track_uses_template_and_disc_folder(monkeypatch, tmp_path):
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$albumartist - $album/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
calls = []
def fake_get_album_tracks_for_source(source, album_id):
calls.append((source, album_id))
return {
"items": [
{"disc_number": 1},
{"disc_number": 2},
]
}
monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", fake_get_album_tracks_for_source)
context = {
"artist": {"name": "Artist One"},
"album": {
"name": "Album One",
"id": "album-1",
"release_date": "2026-01-01",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Artist One"}],
},
"track_info": {
"name": "Song One",
"id": "track-1",
"track_number": 4,
"disc_number": 1,
"artists": [{"name": "Artist One"}],
},
"original_search_result": {
"title": "Song One",
"clean_title": "Song One",
"clean_album": "Album One",
"clean_artist": "Artist One",
"artists": [{"name": "Artist One"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, created = import_paths.build_final_path_for_track(
context,
{"name": "Artist One"},
{
"is_album": True,
"album_name": "Album One",
"track_number": 4,
"disc_number": 1,
},
".flac",
)
assert created is True
assert calls == [("deezer", "album-1")]
assert final_path == str(
tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "Disc 1" / "04 - Song One.flac"
)
assert (tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "Disc 1").is_dir()
def test_build_final_path_for_track_uses_track_disc_number_without_provider_lookup(monkeypatch, tmp_path):
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$albumartist - $album/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
calls = []
monkeypatch.setattr(
import_paths,
"_get_album_tracks_for_source",
lambda source, album_id: calls.append((source, album_id)) or None,
)
context = {
"artist": {"name": "Artist One"},
"album": {
"name": "Album One",
"id": "album-1",
"release_date": "2026-01-01",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Artist One"}],
},
"track_info": {
"name": "Song Two",
"id": "track-2",
"track_number": 4,
"disc_number": 2,
"artists": [{"name": "Artist One"}],
},
"original_search_result": {
"title": "Song Two",
"clean_title": "Song Two",
"clean_album": "Album One",
"clean_artist": "Artist One",
"artists": [{"name": "Artist One"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, created = import_paths.build_final_path_for_track(
context,
{"name": "Artist One"},
{
"is_album": True,
"album_name": "Album One",
"track_number": 4,
"disc_number": 2,
},
".flac",
)
assert created is True
assert calls == []
assert final_path == str(
tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "Disc 2" / "04 - Song Two.flac"
)
def test_build_final_path_for_track_with_cdnum_template_skips_disc_folder(monkeypatch, tmp_path):
"""When the user template encodes the disc via $cdnum, the auto disc folder must not be added.
Reported by user: multi-disc albums got both a "CDxx" label in the
filename AND a redundant "Disc N" folder. The auto-folder should
suppress when the template already encodes the disc.
"""
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$albumartist - $album/$cdnum - $track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
monkeypatch.setattr(
import_paths,
"_get_album_tracks_for_source",
lambda source, album_id: None,
)
context = {
"artist": {"name": "Artist One"},
"album": {
"name": "Album One",
"id": "album-1",
"release_date": "2026-01-01",
"total_tracks": 24,
"total_discs": 2,
"album_type": "album",
"artists": [{"name": "Artist One"}],
},
"track_info": {
"name": "Song Two",
"id": "track-2",
"track_number": 4,
"disc_number": 2,
"artists": [{"name": "Artist One"}],
},
"original_search_result": {
"title": "Song Two",
"clean_title": "Song Two",
"clean_album": "Album One",
"clean_artist": "Artist One",
"artists": [{"name": "Artist One"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, created = import_paths.build_final_path_for_track(
context,
{"name": "Artist One"},
{
"is_album": True,
"album_name": "Album One",
"track_number": 4,
"disc_number": 2,
},
".flac",
)
assert created is True
# Filename has the CD02 label; NO "Disc 2" folder injected.
assert final_path == str(
tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "CD02 - 04 - Song Two.flac"
)
# Verify the disc folder was not created either.
assert not (tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "Disc 2").exists()
# ── #745: $year must validate, never blind-slice release_date ──────────────
def test_extract_year_accepts_real_dates():
assert import_paths._extract_year_from_release_date("2026-01-01") == "2026"
assert import_paths._extract_year_from_release_date("1999") == "1999"
assert import_paths._extract_year_from_release_date("2026") == "2026"
# Datetime-ish string — still leads with a valid year.
assert import_paths._extract_year_from_release_date("2010-12-31T00:00:00Z") == "2010"
def test_extract_year_rejects_non_date_values():
# #745 exact case: release_date poisoned with the album NAME.
assert import_paths._extract_year_from_release_date("Mantras (Deluxe)") == ""
assert import_paths._extract_year_from_release_date("Mant") == ""
# Implausible / sentinel years are rejected.
assert import_paths._extract_year_from_release_date("0000") == ""
assert import_paths._extract_year_from_release_date("1800") == ""
assert import_paths._extract_year_from_release_date("9999") == ""
# Empty / None.
assert import_paths._extract_year_from_release_date("") == ""
assert import_paths._extract_year_from_release_date(None) == ""
# Fewer than 4 leading digits.
assert import_paths._extract_year_from_release_date("202") == ""
def test_build_final_path_drops_garbage_year_from_folder(monkeypatch, tmp_path):
"""#745 reproduction: release_date carries the album NAME, not a date.
The $year slot must resolve to empty and the bracket cleanup must drop the
empty () — producing 'Album One' NOT 'Album One (Mant)'."""
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
# Template that uses $year, like the reporter's.
"album_path": "$albumartist/$album ($year) [$albumtype]/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
monkeypatch.setattr(
import_paths, "_get_album_tracks_for_source",
lambda source, album_id: {"items": [{"disc_number": 1}]},
)
context = {
"artist": {"name": "Katie Pruitt"},
"album": {
"name": "Mantras (Deluxe)",
"id": "album-1",
# POISONED: the album name landed in release_date (the #745 bug).
"release_date": "Mantras (Deluxe)",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Katie Pruitt"}],
},
"track_info": {
"name": "White Lies, White Jesus And You",
"id": "track-1",
"track_number": 1,
"disc_number": 1,
"artists": [{"name": "Katie Pruitt"}],
},
"original_search_result": {
"title": "White Lies, White Jesus And You",
"clean_title": "White Lies, White Jesus And You",
"clean_album": "Mantras (Deluxe)",
"clean_artist": "Katie Pruitt",
"artists": [{"name": "Katie Pruitt"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, created = import_paths.build_final_path_for_track(
context,
{"name": "Katie Pruitt"},
{"is_album": True, "album_name": "Mantras (Deluxe)", "track_number": 1, "disc_number": 1},
".flac",
)
assert created is True
# The album folder must NOT contain "(Mant)" or any "(...)" year artifact.
album_folder = os.path.basename(os.path.dirname(final_path))
assert "(Mant" not in album_folder
assert "()" not in album_folder
# Empty () collapses; [Album] type stays.
assert album_folder == "Mantras (Deluxe) [Album]"
def test_build_final_path_keeps_real_year_in_folder(monkeypatch, tmp_path):
"""Positive control: a genuine release_date still produces the (YYYY)
folder — proves the guard didn't break the happy path."""
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$album ($year) [$albumtype]/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
monkeypatch.setattr(
import_paths, "_get_album_tracks_for_source",
lambda source, album_id: {"items": [{"disc_number": 1}]},
)
context = {
"artist": {"name": "Katie Pruitt"},
"album": {
"name": "Mantras (Deluxe)",
"id": "album-1",
"release_date": "2026-04-12",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Katie Pruitt"}],
},
"track_info": {
"name": "White Lies, White Jesus And You",
"id": "track-1",
"track_number": 1,
"disc_number": 1,
"artists": [{"name": "Katie Pruitt"}],
},
"original_search_result": {
"title": "White Lies, White Jesus And You",
"clean_title": "White Lies, White Jesus And You",
"clean_album": "Mantras (Deluxe)",
"clean_artist": "Katie Pruitt",
"artists": [{"name": "Katie Pruitt"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, _ = import_paths.build_final_path_for_track(
context,
{"name": "Katie Pruitt"},
{"is_album": True, "album_name": "Mantras (Deluxe)", "track_number": 1, "disc_number": 1},
".flac",
)
album_folder = os.path.basename(os.path.dirname(final_path))
assert album_folder == "Mantras (Deluxe) (2026) [Album]"