diff --git a/streamrip/filepath_utils.py b/streamrip/filepath_utils.py index 27aef99e..8174e820 100644 --- a/streamrip/filepath_utils.py +++ b/streamrip/filepath_utils.py @@ -20,9 +20,13 @@ def clean_filename(fn: str, restrict: bool = False) -> str: return path -def clean_filepath(fn: str, restrict: bool = False) -> str: +def clean_filepath(fn: str, restrict: bool = False, max_length: int = 200) -> str: path = str(sanitize_filepath(fn)) if restrict: path = "".join(c for c in path if c in ALLOWED_CHARS) + # Truncate path to prevent filesystem issues + if len(path) > max_length: + path = path[:max_length].rstrip() + return path diff --git a/streamrip/media/album.py b/streamrip/media/album.py index 3ca79a29..c4499d6c 100644 --- a/streamrip/media/album.py +++ b/streamrip/media/album.py @@ -112,7 +112,9 @@ def _album_folder(self, parent: str, meta: AlbumMetadata) -> str: parent = os.path.join(parent, self.client.source.capitalize()) formatter = config.filepaths.folder_format folder = clean_filepath( - meta.format_folder_path(formatter), config.filepaths.restrict_characters + meta.format_folder_path(formatter), + config.filepaths.restrict_characters, + max_length=150, # Leave room for parent path and filename ) return os.path.join(parent, folder) diff --git a/streamrip/media/track.py b/streamrip/media/track.py index b09cfa13..aa9d91b9 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -1,6 +1,7 @@ import asyncio import logging import os +import tempfile from dataclasses import dataclass from .. import converter @@ -33,7 +34,17 @@ class Track(Media): async def preprocess(self): self._set_download_path() - os.makedirs(self.folder, exist_ok=True) + try: + os.makedirs(self.folder, exist_ok=True) + except OSError as e: + logger.error(f"Failed to create directory '{self.folder}': {e}") + # Try to create a shorter path as fallback + fallback_folder = os.path.join(tempfile.gettempdir(), "streamrip_fallback") + os.makedirs(fallback_folder, exist_ok=True) + self.folder = fallback_folder + self._set_download_path() # Recalculate with new folder + logger.warning(f"Using fallback directory: {self.folder}") + if self.is_single: add_title(self.meta.title) @@ -104,11 +115,26 @@ def _set_download_path(self): if c.truncate_to > 0 and len(track_path) > c.truncate_to: track_path = track_path[: c.truncate_to] - self.download_path = os.path.join( + # Construct the full path + full_path = os.path.join( self.folder, f"{track_path}.{self.downloadable.extension}", ) + # Check if the full path is too long and truncate if necessary + max_path_length = 250 # Leave some buffer for filesystem limits + if len(full_path) > max_path_length: + # Calculate how much we need to truncate the track_path + excess = len(full_path) - max_path_length + if len(track_path) > excess: + track_path = track_path[:-excess].rstrip() + full_path = os.path.join( + self.folder, + f"{track_path}.{self.downloadable.extension}", + ) + + self.download_path = full_path + @dataclass(slots=True) class PendingTrack(Pending): diff --git a/tests/test_album_filepath_handling.py b/tests/test_album_filepath_handling.py new file mode 100644 index 00000000..ffb830c2 --- /dev/null +++ b/tests/test_album_filepath_handling.py @@ -0,0 +1,369 @@ +import os +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from streamrip.client import Client +from streamrip.config import Config +from streamrip.db import Database +from streamrip.media.album import PendingAlbum +from streamrip.metadata import AlbumMetadata + + +@pytest.fixture +def mock_client() -> Client: + """Fixture providing a mock client.""" + client = Mock(spec=Client) + client.source = "qobuz" + client.session = Mock() + client.get_metadata = AsyncMock() + return client + + +@pytest.fixture +def mock_config() -> Config: + """Fixture providing a mock config with filepath settings.""" + config = Mock(spec=Config) + config.session = Mock() + config.session.downloads = Mock() + config.session.downloads.folder = "/tmp/music" + config.session.downloads.source_subdirectories = False + config.session.filepaths = Mock() + config.session.filepaths.folder_format = ( + "{artist} - {album} ({year}) [{format}] [{bit_depth}B-{sampling_rate}kHz]" + ) + config.session.filepaths.restrict_characters = False + config.session.artwork = Mock() + return config + + +@pytest.fixture +def mock_database() -> Database: + """Fixture providing a mock database.""" + return Mock(spec=Database) + + +@pytest.fixture +def mock_album_metadata() -> AlbumMetadata: + """Fixture providing mock album metadata.""" + metadata = Mock(spec=AlbumMetadata) + metadata.album = "Test Album" + metadata.artist = "Test Artist" + metadata.year = 2023 + metadata.format = "FLAC" + metadata.bit_depth = 24 + metadata.sampling_rate = 96 + metadata.covers = Mock() + metadata.format_folder_path = Mock( + return_value="Test Artist - Test Album (2023) [FLAC] [24B-96kHz]" + ) + return metadata + + +@pytest.fixture +def mock_long_album_metadata() -> AlbumMetadata: + """Fixture providing mock album metadata with very long names.""" + metadata = Mock(spec=AlbumMetadata) + metadata.album = "Very Long Album Name That Exceeds Normal Filesystem Limits" * 3 + metadata.artist = "Very Long Artist Name That Also Exceeds Normal Limits" * 2 + metadata.year = 2023 + metadata.format = "FLAC" + metadata.bit_depth = 24 + metadata.sampling_rate = 96 + metadata.covers = Mock() + + long_path = ( + "Very Long Artist Name That Also Exceeds Normal Limits" * 2 + + " - " + + "Very Long Album Name That Exceeds Normal Filesystem Limits" * 3 + + " (2023) [FLAC] [24B-96kHz]" + ) + metadata.format_folder_path = Mock(return_value=long_path) + return metadata + + +@pytest.fixture +def pending_album( + mock_client: Client, mock_config: Config, mock_database: Database +) -> PendingAlbum: + """Fixture providing a PendingAlbum instance.""" + return PendingAlbum( + id="test_album_id", client=mock_client, config=mock_config, db=mock_database + ) + + +def test_album_folder_normal_length( + pending_album: PendingAlbum, mock_album_metadata: AlbumMetadata +) -> None: + """Test _album_folder with normal-length album metadata.""" + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_album_metadata) + + expected_folder = "Test Artist - Test Album (2023) [FLAC] [24B-96kHz]" + assert expected_folder in result + assert result.startswith(parent) + + +def test_album_folder_respects_max_length( + pending_album: PendingAlbum, mock_long_album_metadata: AlbumMetadata +) -> None: + """Test _album_folder respects maximum length limit.""" + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_long_album_metadata) + + # The folder name part should be truncated to 150 characters + folder_name = os.path.basename(result) + assert len(folder_name) <= 150 + assert result.startswith(parent) + + +def test_album_folder_with_source_subdirectories( + pending_album: PendingAlbum, mock_album_metadata: AlbumMetadata +) -> None: + """Test _album_folder with source subdirectories enabled.""" + pending_album.config.session.downloads.source_subdirectories = True + parent = "/tmp/music" + + result = pending_album._album_folder(parent, mock_album_metadata) + + # Should include source subdirectory + assert "Qobuz" in result # Capitalized source name + assert "Test Artist - Test Album" in result + + +def test_album_folder_restrict_characters( + pending_album: PendingAlbum, mock_album_metadata: AlbumMetadata +) -> None: + """Test _album_folder with character restriction enabled.""" + pending_album.config.session.filepaths.restrict_characters = True + + # Set up metadata with problematic characters + problematic_path = "Test<>Artist - Test|Album*Name (2023) [FLAC]" + mock_album_metadata.format_folder_path.return_value = problematic_path + + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_album_metadata) + + # Should not contain restricted characters in the folder name + folder_name = os.path.basename(result) + restricted_chars = ["<", ">", "|", "*"] + for char in restricted_chars: + # clean_filepath should handle these through pathvalidate + assert char not in folder_name, ( + f"Restricted character '{char}' found in folder name: {folder_name}" + ) + + # Verify the result is a valid path + assert folder_name # Should not be empty + assert len(folder_name.encode()) <= 150 # Should respect max_length parameter + + +@pytest.mark.parametrize( + ("parent_length", "album_name_length"), + [ + (50, 100), # Moderate parent, long album + (100, 200), # Long parent, very long album + (200, 50), # Very long parent, moderate album + (10, 300), # Short parent, extremely long album + ], +) +def test_album_folder_various_lengths( + pending_album: PendingAlbum, parent_length: int, album_name_length: int +) -> None: + """Test _album_folder with various parent and album name lengths.""" + parent = "/tmp/" + "a" * parent_length + + # Create mock metadata with specified length + mock_metadata = Mock(spec=AlbumMetadata) + long_album_path = "b" * album_name_length + mock_metadata.format_folder_path = Mock(return_value=long_album_path) + + result = pending_album._album_folder(parent, mock_metadata) + + # The album folder part should be limited to 150 characters + folder_name = os.path.basename(result) + assert len(folder_name) <= 150 + assert result.startswith(parent) + + +def test_album_folder_handles_empty_format(pending_album: PendingAlbum) -> None: + """Test _album_folder handles empty format results.""" + mock_metadata = Mock(spec=AlbumMetadata) + mock_metadata.format_folder_path = Mock(return_value="") + + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_metadata) + + # Should still return a valid path + assert result.startswith(parent) + + +def test_album_folder_strips_trailing_whitespace(pending_album: PendingAlbum) -> None: + """Test _album_folder strips trailing whitespace after truncation.""" + mock_metadata = Mock(spec=AlbumMetadata) + # Create a path that when truncated will have trailing spaces + path_with_spaces = "Album Name With Trailing Spaces" + " " * 200 + mock_metadata.format_folder_path = Mock(return_value=path_with_spaces) + + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_metadata) + + # Should not end with spaces + folder_name = os.path.basename(result) + assert not folder_name.endswith(" ") + + +@pytest.mark.parametrize( + ("source", "expected_subdir"), + [ + ("qobuz", "Qobuz"), + ("deezer", "Deezer"), + ("tidal", "Tidal"), + ("soundcloud", "Soundcloud"), + ], +) +def test_album_folder_source_subdirectory_names( + mock_config: Config, + mock_database: Database, + mock_album_metadata: AlbumMetadata, + source: str, + expected_subdir: str, +) -> None: + """Test _album_folder creates correct source subdirectory names.""" + mock_client = Mock(spec=Client) + mock_client.source = source + + pending_album = PendingAlbum( + id="test_id", client=mock_client, config=mock_config, db=mock_database + ) + + pending_album.config.session.downloads.source_subdirectories = True + parent = "/tmp/music" + + result = pending_album._album_folder(parent, mock_album_metadata) + + assert expected_subdir in result + + +@pytest.mark.asyncio +async def test_resolve_creates_album_folder( + pending_album: PendingAlbum, mock_album_metadata: AlbumMetadata +) -> None: + """Test that resolve creates the album folder.""" + # Mock the client response with proper Qobuz tracks structure + mock_resp = { + "title": "Test Album", + "artist": {"name": "Test Artist"}, + "tracks": {"items": [{"id": "track1"}, {"id": "track2"}]}, + } + pending_album.client.get_metadata.return_value = mock_resp + + with ( + patch( + "streamrip.media.album.AlbumMetadata.from_album_resp", + return_value=mock_album_metadata, + ), + patch("streamrip.media.album.download_artwork", return_value=(None, None)), + patch("os.makedirs") as mock_makedirs, + ): + await pending_album.resolve() + + # Should have called makedirs for the album folder + mock_makedirs.assert_called() + args, kwargs = mock_makedirs.call_args + assert kwargs.get("exist_ok", False) is True + + +@pytest.mark.asyncio +async def test_resolve_handles_long_paths( + pending_album: PendingAlbum, mock_long_album_metadata: AlbumMetadata +) -> None: + """Test that resolve handles very long album paths correctly.""" + # Mock the client response with proper Qobuz tracks structure + mock_resp = { + "title": "Very Long Album Name", + "artist": {"name": "Very Long Artist Name"}, + "tracks": {"items": [{"id": "track1"}]}, + } + pending_album.client.get_metadata.return_value = mock_resp + + with ( + patch( + "streamrip.media.album.AlbumMetadata.from_album_resp", + return_value=mock_long_album_metadata, + ), + patch("streamrip.media.album.download_artwork", return_value=(None, None)), + patch("os.makedirs") as _, + ): + album = await pending_album.resolve() + + assert album is not None + # The folder path should be truncated appropriately + folder_name = os.path.basename(album.folder) + assert len(folder_name) <= 150 + + +def test_album_folder_unicode_handling(pending_album: PendingAlbum) -> None: + """Test _album_folder handles unicode characters properly.""" + mock_metadata = Mock(spec=AlbumMetadata) + unicode_path = "Artista - Álbum Español (2023) [FLAC]" + mock_metadata.format_folder_path = Mock(return_value=unicode_path) + + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_metadata) + + # Should handle unicode characters + assert "Artista" in result + assert result.startswith(parent) + + +@pytest.mark.parametrize( + ("restrict_chars", "input_path", "should_restrict"), + [ + (True, "Artist - Album (2023)", False), # No problematic chars + (True, "Artist<>Album|Name*", True), # Has problematic chars + (False, "Artist<>Album|Name*", False), # Restriction disabled + ], +) +def test_album_folder_character_restriction_scenarios( + pending_album: PendingAlbum, + restrict_chars: bool, + input_path: str, + should_restrict: bool, +) -> None: + """Test _album_folder character restriction in various scenarios.""" + pending_album.config.session.filepaths.restrict_characters = restrict_chars + + mock_metadata = Mock(spec=AlbumMetadata) + mock_metadata.format_folder_path = Mock(return_value=input_path) + + parent = "/tmp/music" + result = pending_album._album_folder(parent, mock_metadata) + + # Should always return a valid path + assert result.startswith(parent) + folder_name = os.path.basename(result) + assert len(folder_name) <= 150 + + +def test_album_folder_real_world_examples(pending_album: PendingAlbum) -> None: + """Test _album_folder with real-world problematic album names.""" + real_world_examples = [ + "The London Metropolitan Orchestra, Elliot Goldenthal, Steven Mercurio, Jonathan Sheffer, The Mask Orchestra, The Pickled Heads Band - Titus - Original Motion Picture Soundtrack (2000) [FLAC] [16B-44.1kHz]", + "Toronto Symphony Orchestra, Gustavo Gimeno, Isabel Leonard, Paul Appleby, Derek Welton - Stravinsky Pulcinella, ballet with song in one act, K034 XIV. Tarantella (2025) [FLAC] [24B-96kHz]", + "Various Artists - The Complete Collection of Classical Music: Symphonies, Concertos, and Chamber Music (2023) [FLAC] [24B-192kHz]", + ] + + parent = "/tmp/music" + + for example in real_world_examples: + mock_metadata = Mock(spec=AlbumMetadata) + mock_metadata.format_folder_path = Mock(return_value=example) + + result = pending_album._album_folder(parent, mock_metadata) + + # Should be truncated to reasonable length + folder_name = os.path.basename(result) + assert len(folder_name) <= 150 + assert result.startswith(parent) + assert not folder_name.endswith(" ") # No trailing spaces diff --git a/tests/test_filepath_integration.py b/tests/test_filepath_integration.py new file mode 100644 index 00000000..fcefb141 --- /dev/null +++ b/tests/test_filepath_integration.py @@ -0,0 +1,434 @@ +import os +import tempfile +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from streamrip.client import Client +from streamrip.client.downloadable import Downloadable +from streamrip.config import Config +from streamrip.db import Database +from streamrip.media.album import PendingAlbum +from streamrip.media.track import Track +from streamrip.metadata import AlbumMetadata, TrackMetadata + + +@pytest.fixture +def temp_download_dir() -> str: + """Fixture providing a temporary download directory.""" + return tempfile.mkdtemp(prefix="streamrip_test_") + + +@pytest.fixture +def integration_config(temp_download_dir: str) -> Config: + """Fixture providing a config for integration tests.""" + config = Mock(spec=Config) + config.session = Mock() + config.session.downloads = Mock() + config.session.downloads.folder = temp_download_dir + config.session.downloads.source_subdirectories = False + config.session.downloads.downloads = 3 + config.session.filepaths = Mock() + config.session.filepaths.folder_format = ( + "{artist} - {album} ({year}) [{format}] [{bit_depth}B-{sampling_rate}kHz]" + ) + config.session.filepaths.track_format = "{tracknumber:02d}. {artist} - {title}" + config.session.filepaths.restrict_characters = False + config.session.filepaths.truncate_to = 0 + config.session.filepaths.add_singles_to_folder = True + config.session.artwork = Mock() + config.session.conversion = Mock() + config.session.conversion.enabled = False + config.session.cli = Mock() + config.session.cli.progress_bars = False + return config + + +@pytest.fixture +def mock_client() -> Client: + """Fixture providing a mock client for integration tests.""" + client = Mock(spec=Client) + client.source = "qobuz" + client.session = Mock() + client.get_metadata = AsyncMock() + client.get_downloadable = AsyncMock() + return client + + +@pytest.fixture +def mock_database() -> Database: + """Fixture providing a mock database.""" + db = Mock(spec=Database) + db.downloaded = Mock(return_value=False) + db.set_downloaded = Mock() + db.set_failed = Mock() + return db + + +@pytest.fixture +def problematic_album_metadata() -> AlbumMetadata: + """Fixture providing album metadata that would cause filepath issues.""" + metadata = Mock(spec=AlbumMetadata) + metadata.album = "Very Long Album Name That Exceeds Normal Filesystem Limits And Contains Special Characters <>/|*?" + metadata.artist = "Very Long Artist Name That Also Exceeds Normal Limits And Has Unicode Characters ñáéíóú" + metadata.year = 2023 + metadata.format = "FLAC" + metadata.bit_depth = 24 + metadata.sampling_rate = 96 + metadata.covers = Mock() + metadata.disctotal = 1 + + long_path = f"{metadata.artist} - {metadata.album} ({metadata.year}) [{metadata.format}] [{metadata.bit_depth}B-{metadata.sampling_rate}kHz]" + metadata.format_folder_path = Mock(return_value=long_path) + return metadata + + +@pytest.fixture +def problematic_track_metadata( + problematic_album_metadata: AlbumMetadata, +) -> TrackMetadata: + """Fixture providing track metadata that would cause filepath issues.""" + metadata = Mock(spec=TrackMetadata) + metadata.title = "Very Long Track Title That Exceeds Normal Limits And Contains Special Characters <>/|*?" + metadata.artist = problematic_album_metadata.artist + metadata.album = problematic_album_metadata.album + metadata.tracknumber = 1 + metadata.discnumber = 1 + metadata.info = Mock() + metadata.info.id = "test_track_id" + + track_path = f"{metadata.tracknumber:02d}. {metadata.artist} - {metadata.title}" + metadata.format_track_path = Mock(return_value=track_path) + return metadata + + +@pytest.fixture +def mock_downloadable() -> Downloadable: + """Fixture providing a mock downloadable.""" + downloadable = Mock(spec=Downloadable) + downloadable.extension = "flac" + downloadable.size = AsyncMock(return_value=1024000) + downloadable.download = AsyncMock() + downloadable.source = "qobuz" + return downloadable + + +def test_direct_album_folder_path_handling( + integration_config: Config, + mock_client: Client, + mock_database: Database, + problematic_album_metadata: AlbumMetadata, + temp_download_dir: str, +) -> None: + """Test album folder path handling directly without complex async resolution.""" + pending_album = PendingAlbum( + id="test_album_id", + client=mock_client, + config=integration_config, + db=mock_database, + ) + + # Test the _album_folder method directly + album_folder = pending_album._album_folder( + temp_download_dir, problematic_album_metadata + ) + + # Check that the folder path is reasonable + folder_name = os.path.basename(album_folder) + assert len(folder_name) <= 150 + assert album_folder.startswith(temp_download_dir) + + # Test with source subdirectories + integration_config.session.downloads.source_subdirectories = True + album_folder_with_source = pending_album._album_folder( + temp_download_dir, problematic_album_metadata + ) + assert "Qobuz" in album_folder_with_source + folder_name_with_source = os.path.basename(album_folder_with_source) + assert len(folder_name_with_source) <= 150 + + +def test_direct_track_path_handling( + integration_config: Config, + problematic_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, + mock_database: Database, + temp_download_dir: str, +) -> None: + """Test track path handling directly without complex async resolution.""" + # Create a track with a very long folder path (simulating the real-world error) + long_folder = os.path.join( + temp_download_dir, + "Very Long Artist Name That Also Exceeds Normal Limits And Has Unicode Characters ñáéíóú - Very Long Album Name That Exceeds Normal Filesystem Limits And Contains Special Characters (2023) [FLAC] [24B-96kHz]", + ) + + track = Track( + meta=problematic_track_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder=long_folder, + cover_path=None, + db=mock_database, + ) + + # Test the _set_download_path method directly + track._set_download_path() + + # Check that the resulting path is reasonable + assert len(track.download_path) <= 250 + assert track.download_path.endswith(".flac") + assert track.download_path # Should not be empty + + # Test with different truncate_to values + for truncate_to in [50, 100, 200]: + integration_config.session.filepaths.truncate_to = truncate_to + track._set_download_path() + + if truncate_to > 0: + filename = os.path.basename(track.download_path) + filename_without_ext = filename.rsplit(".", 1)[0] + assert len(filename_without_ext) <= truncate_to + + assert len(track.download_path) <= 250 + + +@pytest.mark.parametrize( + ("restrict_characters", "source_subdirs"), + [ + (True, False), + (False, True), + (True, True), + (False, False), + ], +) +def test_filepath_handling_with_various_configs( + integration_config: Config, + mock_client: Client, + mock_database: Database, + problematic_album_metadata: AlbumMetadata, + problematic_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, + restrict_characters: bool, + source_subdirs: bool, + temp_download_dir: str, +) -> None: + """Test filepath handling with various configuration combinations.""" + integration_config.session.filepaths.restrict_characters = restrict_characters + integration_config.session.downloads.source_subdirectories = source_subdirs + + pending_album = PendingAlbum( + id="test_album_id", + client=mock_client, + config=integration_config, + db=mock_database, + ) + + # Test the _album_folder method directly + album_folder = pending_album._album_folder( + temp_download_dir, problematic_album_metadata + ) + + # Check source subdirectory handling + if source_subdirs: + assert "Qobuz" in album_folder + + # Check path lengths are reasonable + folder_name = os.path.basename(album_folder) + assert len(folder_name) <= 150 + + # Test track path handling with the same config + track = Track( + meta=problematic_track_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder=album_folder, + cover_path=None, + db=mock_database, + ) + + track._set_download_path() + assert len(track.download_path) <= 250 + assert track.download_path.endswith(".flac") + + +@pytest.mark.asyncio +async def test_directory_creation_failure_fallback( + integration_config: Config, + mock_client: Client, + mock_database: Database, + problematic_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, +) -> None: + """Test that directory creation failures are handled with fallback.""" + # Create a track with an impossible directory path + track = Track( + meta=problematic_track_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder="/definitely/invalid/path/that/cannot/be/created", + cover_path=None, + db=mock_database, + ) + + with patch("tempfile.gettempdir", return_value="/tmp"): + await track.preprocess() + + # Should have fallen back to a temp directory + assert "streamrip_fallback" in track.folder + assert track.download_path # Should have a valid download path + + +@pytest.mark.parametrize("truncate_to", [50, 100, 200, 0]) +def test_track_path_truncation_integration( + integration_config: Config, + problematic_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, + mock_database: Database, + truncate_to: int, +) -> None: + """Test track path truncation with various truncate_to values.""" + integration_config.session.filepaths.truncate_to = truncate_to + + track = Track( + meta=problematic_track_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder="/tmp/test", + cover_path=None, + db=mock_database, + ) + + track._set_download_path() + + if truncate_to > 0: + # Check that the filename part is truncated + filename = os.path.basename(track.download_path) + filename_without_ext = filename.rsplit(".", 1)[0] + assert len(filename_without_ext) <= truncate_to + + # Path should always be reasonable length overall + assert len(track.download_path) <= 250 + + +def test_real_world_error_case_simulation( + integration_config: Config, mock_database: Database, mock_downloadable: Downloadable +) -> None: + """Test simulation of the real-world error cases from the issue.""" + # Simulate the exact problematic paths from the error messages + error_cases = [ + { + "folder": "M:/The London Metropolitan Orchestra, Elliot Goldenthal, Steven Mercurio, Jonathan Sheffer, The Mask Orchestra, The Pickled Heads Band - Titus - Original Motion Picture Soundtrack (2000) [FLAC] [16B-44.1kHz]", + "filename": "11. Elliot Goldenthal - Pickled Heads (Instrumental)", + }, + { + "folder": "M:/Toronto Symphony Orchestra, Gustavo Gimeno, Isabel Leonard, Paul Appleby, Derek Welton - Stravinsky Pulcinella, ballet with song in one act, K034 XIV. Tarantella (2025) [FLAC] [24B-96kHz]", + "filename": "01. Toronto Symphony Orchestra - Pulcinella, ballet with song in one act, K034 XIV. Tarantella", + }, + ] + + for case in error_cases: + # Create mock metadata that would generate these paths + mock_metadata = Mock(spec=TrackMetadata) + mock_metadata.format_track_path = Mock(return_value=case["filename"]) + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder=case["folder"], + cover_path=None, + db=mock_database, + ) + + track._set_download_path() + + # The resulting path should be manageable + assert len(track.download_path) <= 250 + assert track.download_path.endswith(".flac") + + # Should not be empty + assert track.download_path + + +@pytest.mark.parametrize( + "unicode_content", + [ + "Artista Español - Álbum con Acentos", + "Русский Исполнитель - Русский Альбом", + "日本のアーティスト - 日本のアルバム", + "Künstler - Ümlauts und Spëcial Chärs", + ], +) +def test_unicode_filepath_handling( + integration_config: Config, + mock_database: Database, + mock_downloadable: Downloadable, + unicode_content: str, +) -> None: + """Test filepath handling with various unicode characters.""" + mock_metadata = Mock(spec=TrackMetadata) + mock_metadata.format_track_path = Mock(return_value=unicode_content) + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder="/tmp/test", + cover_path=None, + db=mock_database, + ) + + track._set_download_path() + + # Should handle unicode gracefully + assert track.download_path + assert track.download_path.endswith(".flac") + assert len(track.download_path) <= 250 + + # Test with character restriction + integration_config.session.filepaths.restrict_characters = True + track._set_download_path() + + # Should still work with restriction + assert track.download_path + assert track.download_path.endswith(".flac") + + +def test_real_world_windows_error_case( + integration_config: Config, mock_database: Database, mock_downloadable: Downloadable +) -> None: + """Test the exact error case from the user's Windows example.""" + # Simulate the exact problematic path from the error message + problematic_folder = "M:/Toronto Symphony Orchestra, Gustavo Gimeno, Isabel Leonard, Paul Appleby, Derek Welton - Stravinsky Pulcinella, ballet with song in one act, K034 XIV. Tarantella (2025) [FLAC] [24B-96kHz]" + problematic_filename = "01. Toronto Symphony Orchestra - Pulcinella, ballet with song in one act, K034 XIV. Tarantella" + + # Create mock metadata that would generate this path + mock_metadata = Mock(spec=TrackMetadata) + mock_metadata.format_track_path = Mock(return_value=problematic_filename) + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=integration_config, + folder=problematic_folder, + cover_path=None, + db=mock_database, + ) + + # This should not raise an exception and should produce a manageable path + track._set_download_path() + + # Verify the path is manageable (Windows has a 260 character limit) + assert len(track.download_path) <= 250 + assert track.download_path.endswith(".flac") + assert track.download_path # Should not be empty + + # The filename part should be truncated if necessary + filename = os.path.basename(track.download_path) + assert len(filename) <= 200 # Leave room for folder path + + print( + f"Original problematic path would be: {len(problematic_folder + '/' + problematic_filename + '.flac')} characters" + ) + print(f"Handled path is: {len(track.download_path)} characters") + print(f"Final path: {track.download_path}") diff --git a/tests/test_filepath_utils.py b/tests/test_filepath_utils.py new file mode 100644 index 00000000..b3b5ebd2 --- /dev/null +++ b/tests/test_filepath_utils.py @@ -0,0 +1,287 @@ +from string import printable + +import pytest + +from streamrip.filepath_utils import ( + ALLOWED_CHARS, + clean_filename, + clean_filepath, + truncate_str, +) + + +@pytest.fixture +def long_unicode_string() -> str: + """Fixture providing a long string with unicode characters.""" + return "test_" + "ñ" * 100 # Each ñ is 2 bytes in UTF-8 + + +@pytest.fixture +def problematic_filename() -> str: + """Fixture providing a filename with problematic characters.""" + return "file<>|*?/with\\invalid:chars.mp3" + + +@pytest.fixture +def real_world_long_paths() -> list[str]: + """Fixture providing real-world problematic paths from error messages.""" + return [ + "M:/The London Metropolitan Orchestra, Elliot Goldenthal, Steven Mercurio, Jonathan Sheffer, The Mask Orchestra, The Pickled Heads Band - Titus - Original Motion Picture Soundtrack (2000) [FLAC] [16B-44.1kHz]/11. Elliot Goldenthal - Pickled Heads (Instrumental).flac", + "M:/Toronto Symphony Orchestra, Gustavo Gimeno, Isabel Leonard, Paul Appleby, Derek Welton - Stravinsky Pulcinella, ballet with song in one act, K034 XIV. Tarantella (2025) [FLAC] [24B-96kHz]/01. Toronto Symphony Orchestra - Pulcinella, ballet with song in one act, K034 XIV. Tarantella.flac", + "Very/Long/Path/With/Many/Nested/Directories/And/A/Very/Long/Filename/That/Could/Cause/Issues.flac", + ] + + +@pytest.mark.parametrize( + ("input_str", "expected_max_bytes"), + [ + ("short", 255), + ("a" * 255, 255), + ("a" * 300, 255), + ("", 255), + ], +) +def test_truncate_str_respects_byte_limit( + input_str: str, expected_max_bytes: int +) -> None: + """Test that truncate_str respects byte length limits.""" + result = truncate_str(input_str) + assert len(result.encode()) <= expected_max_bytes + if len(input_str.encode()) <= expected_max_bytes: + assert result == input_str + + +def test_truncate_str_unicode_handling(long_unicode_string: str) -> None: + """Test that truncate_str handles unicode characters properly.""" + result = truncate_str(long_unicode_string) + + # Result should be valid UTF-8 (no broken characters) + assert result.encode("utf-8") # Should not raise exception + assert len(result.encode()) <= 255 + + +def test_truncate_str_empty_string() -> None: + """Test truncate_str with empty string.""" + assert truncate_str("") == "" + + +@pytest.mark.parametrize( + ("filename", "restrict", "expected_behavior"), + [ + ("normal_file.mp3", False, "exact_match"), + ("file with spaces.flac", False, "exact_match"), + ("very_long_filename_" + "a" * 300 + ".mp3", False, "truncated"), + ], +) +def test_clean_filename_basic_functionality( + filename: str, restrict: bool, expected_behavior: str +) -> None: + """Test basic filename cleaning functionality.""" + result = clean_filename(filename, restrict=restrict) + + if expected_behavior == "exact_match" and len(filename.encode()) <= 255: + assert filename in result or result == filename + elif expected_behavior == "truncated": + # For very long filenames, just ensure it's truncated and valid + assert len(result.encode()) <= 255 + assert result # Should not be empty + + assert len(result.encode()) <= 255 + + +def test_clean_filename_sanitizes_invalid_chars(problematic_filename: str) -> None: + """Test that clean_filename sanitizes invalid characters.""" + result = clean_filename(problematic_filename, restrict=False) + # Should not contain the problematic characters after sanitization + invalid_chars = ["<", ">", "|", "*", "?"] + # pathvalidate should handle these, result should be safe + for char in invalid_chars: + assert char not in result, ( + f"Invalid character '{char}' found in sanitized filename: {result}" + ) + assert len(result.encode()) <= 255 + + +@pytest.mark.parametrize( + ("filename", "restrict"), + [ + ("file with spaces.mp3", True), + ("file\x00\x01\x02.mp3", True), + ("file\n\t.mp3", True), + ], +) +def test_clean_filename_restrict_characters(filename: str, restrict: bool) -> None: + """Test filename cleaning with character restriction.""" + result = clean_filename(filename, restrict=restrict) + + if restrict: + # All characters should be in ALLOWED_CHARS + assert all(c in ALLOWED_CHARS for c in result) + + +def test_clean_filename_unicode_handling() -> None: + """Test filename cleaning with unicode characters.""" + filename = "track_ñáéíóú.mp3" + result = clean_filename(filename, restrict=False) + assert "track" in result + assert ".mp3" in result + + result_restricted = clean_filename(filename, restrict=True) + assert "track" in result_restricted + # Unicode chars should be removed when restricted + assert "ñ" not in result_restricted + + +@pytest.mark.parametrize( + ("filepath", "restrict", "max_length"), + [ + ("path/to/file.mp3", False, 200), + ("very/long/path/" + "a" * 300 + "/file.mp3", False, 100), + ("path/to/file.mp3", False, 10), + ("", False, 200), + ], +) +def test_clean_filepath_respects_max_length( + filepath: str, restrict: bool, max_length: int +) -> None: + """Test that clean_filepath respects max_length parameter.""" + result = clean_filepath(filepath, restrict=restrict, max_length=max_length) + assert len(result) <= max_length + + +@pytest.mark.parametrize( + ("filepath", "max_length"), + [ + ("path/to/very/long/filename/that/exceeds/limits.mp3", 50), + ("short.mp3", 200), + ("medium/length/path/file.flac", 100), + ], +) +def test_clean_filepath_truncation_behavior(filepath: str, max_length: int) -> None: + """Test filepath truncation behavior.""" + result = clean_filepath(filepath, max_length=max_length) + assert len(result) <= max_length + + if len(filepath) > max_length: + # Should be truncated and stripped of trailing whitespace + assert len(result) <= max_length + assert not result.endswith(" ") + + +def test_clean_filepath_restrict_characters() -> None: + """Test filepath cleaning with character restriction.""" + filepath = "path/to/file\x00\x01.mp3" + result = clean_filepath(filepath, restrict=True) + assert all(c in ALLOWED_CHARS for c in result) + + +@pytest.mark.parametrize( + ("filepath", "expected_preserved"), + [ + ("path/to/file.mp3", True), + ("path\\to\\file.mp3", True), # Windows paths + ("path/with spaces/file.mp3", True), + ], +) +def test_clean_filepath_preserves_structure( + filepath: str, expected_preserved: bool +) -> None: + """Test that clean_filepath preserves basic path structure.""" + result = clean_filepath(filepath, restrict=False) + # Should maintain some recognizable structure + if expected_preserved and len(filepath) <= 200: + # Basic components should be recognizable + assert len(result) > 0 + + +def test_clean_filepath_default_max_length() -> None: + """Test that clean_filepath uses default max_length of 200.""" + long_path = "path/" + "a" * 300 + "/file.mp3" + result = clean_filepath(long_path) + assert len(result) <= 200 + + +@pytest.mark.parametrize( + ("edge_case_path", "expected_max_length"), + [ + ("", 200), + ("///", 200), + ("path/to/file.mp3", 5), + ], +) +def test_clean_filepath_edge_cases( + edge_case_path: str, expected_max_length: int +) -> None: + """Test edge cases for clean_filepath.""" + result = clean_filepath(edge_case_path, max_length=expected_max_length) + assert len(result) <= expected_max_length + + +def test_allowed_chars_contains_printable() -> None: + """Test that ALLOWED_CHARS contains all printable characters.""" + assert ALLOWED_CHARS == set(printable) + + +@pytest.mark.parametrize("non_printable_char", ["\x00", "\x01", "\x02", "\x1f"]) +def test_allowed_chars_excludes_non_printable(non_printable_char: str) -> None: + """Test that ALLOWED_CHARS excludes non-printable characters.""" + assert non_printable_char not in ALLOWED_CHARS + + +@pytest.mark.parametrize("max_length", [200, 150, 100]) +def test_real_world_problematic_paths( + real_world_long_paths: list[str], max_length: int +) -> None: + """Test filepath utilities with real-world problematic paths.""" + for problematic_path in real_world_long_paths: + result = clean_filepath(problematic_path, max_length=max_length) + + # Should be within length limits + assert len(result) <= max_length + + # Should not be empty (unless input was empty) + if problematic_path: + assert result + + # Should not end with whitespace + assert not result.endswith(" ") + + +def test_filename_and_filepath_consistency() -> None: + """Test that clean_filename and clean_filepath work consistently.""" + test_name = "problematic<>file|name?.mp3" + + filename_result = clean_filename(test_name) + filepath_result = clean_filepath(f"path/to/{test_name}") + + # Both should produce valid results + assert len(filename_result.encode()) <= 255 + assert len(filepath_result) <= 200 # default max_length + + +def test_clean_filepath_handles_windows_long_paths() -> None: + """Test that clean_filepath handles Windows-style long paths.""" + windows_path = "C:\\Users\\Username\\Music\\Very Long Artist Name\\Very Long Album Name (Year) [Format]\\Very Long Track Name.flac" + result = clean_filepath(windows_path, max_length=200) + + assert len(result) <= 200 + assert result # Should not be empty + assert not result.endswith(" ") + + +@pytest.mark.parametrize( + "unicode_path", + [ + "path/to/ñáéíóú/file.mp3", + "路径/到/文件.mp3", + "путь/к/файлу.mp3", + ], +) +def test_clean_filepath_unicode_paths(unicode_path: str) -> None: + """Test clean_filepath with various unicode characters.""" + result = clean_filepath(unicode_path, restrict=False, max_length=200) + assert len(result) <= 200 + + result_restricted = clean_filepath(unicode_path, restrict=True, max_length=200) + assert len(result_restricted) <= 200 + assert all(c in ALLOWED_CHARS for c in result_restricted) diff --git a/tests/test_track_filepath_handling.py b/tests/test_track_filepath_handling.py new file mode 100644 index 00000000..0869dde7 --- /dev/null +++ b/tests/test_track_filepath_handling.py @@ -0,0 +1,365 @@ +import os +import tempfile +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from streamrip.client.downloadable import Downloadable +from streamrip.config import Config +from streamrip.db import Database +from streamrip.media.track import Track +from streamrip.metadata import AlbumMetadata, TrackMetadata + + +@pytest.fixture +def mock_config() -> Config: + """Fixture providing a mock config with filepath settings.""" + config = Mock(spec=Config) + config.session = Mock() + config.session.filepaths = Mock() + config.session.filepaths.track_format = "{tracknumber}. {artist} - {title}" + config.session.filepaths.restrict_characters = False + config.session.filepaths.truncate_to = 0 + config.session.downloads = Mock() + config.session.downloads.downloads = 3 + return config + + +@pytest.fixture +def mock_track_metadata() -> TrackMetadata: + """Fixture providing mock track metadata.""" + metadata = Mock(spec=TrackMetadata) + metadata.title = "Test Track Title" + metadata.artist = "Test Artist" + metadata.tracknumber = 1 + metadata.format_track_path = Mock(return_value="01. Test Artist - Test Track Title") + return metadata + + +@pytest.fixture +def mock_album_metadata() -> AlbumMetadata: + """Fixture providing mock album metadata.""" + metadata = Mock(spec=AlbumMetadata) + metadata.album = "Test Album" + metadata.artist = "Test Artist" + metadata.year = 2023 + return metadata + + +@pytest.fixture +def mock_downloadable() -> Downloadable: + """Fixture providing mock downloadable.""" + downloadable = Mock(spec=Downloadable) + downloadable.extension = "flac" + downloadable.size = AsyncMock(return_value=1024000) + downloadable.download = AsyncMock() + return downloadable + + +@pytest.fixture +def mock_database() -> Database: + """Fixture providing mock database.""" + return Mock(spec=Database) + + +@pytest.fixture +def temp_folder() -> str: + """Fixture providing a temporary folder path.""" + return tempfile.mkdtemp() + + +@pytest.fixture +def track_with_normal_path( + mock_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, + mock_config: Config, + temp_folder: str, + mock_database: Database, +) -> Track: + """Fixture providing a track with normal-length paths.""" + return Track( + meta=mock_track_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder=temp_folder, + cover_path=None, + db=mock_database, + ) + + +@pytest.fixture +def track_with_long_path( + mock_downloadable: Downloadable, + mock_config: Config, + temp_folder: str, + mock_database: Database, +) -> Track: + """Fixture providing a track with very long paths.""" + long_metadata = Mock(spec=TrackMetadata) + long_metadata.title = "Very Long Track Title That Exceeds Normal Limits" * 5 + long_metadata.artist = "Very Long Artist Name That Also Exceeds Limits" * 3 + long_metadata.tracknumber = 1 + long_metadata.format_track_path = Mock( + return_value="01. " + + "Very Long Artist Name That Also Exceeds Limits" * 3 + + " - " + + "Very Long Track Title That Exceeds Normal Limits" * 5 + ) + + return Track( + meta=long_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder=temp_folder, + cover_path=None, + db=mock_database, + ) + + +def test_set_download_path_normal_length(track_with_normal_path: Track) -> None: + """Test _set_download_path with normal-length paths.""" + track_with_normal_path._set_download_path() + + assert track_with_normal_path.download_path + assert track_with_normal_path.download_path.endswith(".flac") + assert "Test Artist" in track_with_normal_path.download_path + assert "Test Track Title" in track_with_normal_path.download_path + + +def test_set_download_path_respects_max_length(track_with_long_path: Track) -> None: + """Test _set_download_path respects maximum path length.""" + track_with_long_path._set_download_path() + + # Path should be truncated to reasonable length + assert len(track_with_long_path.download_path) <= 250 + assert track_with_long_path.download_path.endswith(".flac") + + +@pytest.mark.parametrize( + ("truncate_to", "expected_truncated"), + [ + (50, True), + (0, False), # No truncation + (200, False), # Longer than typical track name + ], +) +def test_set_download_path_config_truncation( + track_with_normal_path: Track, truncate_to: int, expected_truncated: bool +) -> None: + """Test _set_download_path respects config truncation settings.""" + track_with_normal_path.config.session.filepaths.truncate_to = truncate_to + original_format_result = "01. Test Artist - Test Track Title" + track_with_normal_path.meta.format_track_path.return_value = original_format_result + + track_with_normal_path._set_download_path() + + if expected_truncated and truncate_to > 0: + # Should be truncated + filename = os.path.basename(track_with_normal_path.download_path) + filename_without_ext = filename.rsplit(".", 1)[0] + assert len(filename_without_ext) <= truncate_to + else: + # Should contain full original content + assert "Test Artist" in track_with_normal_path.download_path + assert "Test Track Title" in track_with_normal_path.download_path + + +def test_set_download_path_restrict_characters(track_with_normal_path: Track) -> None: + """Test _set_download_path with character restriction enabled.""" + track_with_normal_path.config.session.filepaths.restrict_characters = True + track_with_normal_path.meta.format_track_path.return_value = ( + "01. Test<>Artist - Test|Track*Title" + ) + + track_with_normal_path._set_download_path() + + # Should not contain restricted characters + path = track_with_normal_path.download_path + restricted_chars = ["<", ">", "|", "*"] + for char in restricted_chars: + assert char not in path + + +@pytest.mark.parametrize( + ("folder_length", "filename_length"), + [ + (50, 100), # Moderate folder, long filename + (100, 100), # Both moderate + (150, 50), # Long folder, short filename + (100, 200), # Moderate folder, very long filename + ], +) +def test_set_download_path_various_lengths( + mock_track_metadata: TrackMetadata, + mock_downloadable: Downloadable, + mock_config: Config, + mock_database: Database, + folder_length: int, + filename_length: int, +) -> None: + """Test _set_download_path with various folder and filename lengths.""" + long_folder = "/tmp/" + "a" * folder_length + long_filename = "b" * filename_length + + mock_track_metadata.format_track_path.return_value = long_filename + + track = Track( + meta=mock_track_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder=long_folder, + cover_path=None, + db=mock_database, + ) + + track._set_download_path() + + # Total path should be within reasonable limits + assert len(track.download_path) <= 250 + assert track.download_path.endswith(".flac") + + +@pytest.mark.asyncio +async def test_preprocess_creates_directory( + track_with_normal_path: Track, temp_folder: str +) -> None: + """Test that preprocess creates the target directory.""" + # Remove the temp folder to test creation + if os.path.exists(temp_folder): + os.rmdir(temp_folder) + + await track_with_normal_path.preprocess() + + assert os.path.exists(temp_folder) + assert os.path.isdir(temp_folder) + + +@pytest.mark.asyncio +async def test_preprocess_handles_directory_creation_failure( + track_with_normal_path: Track, +) -> None: + """Test that preprocess handles directory creation failures gracefully.""" + # Set an invalid folder path that will cause OSError + track_with_normal_path.folder = "/invalid/path/that/cannot/be/created" + + with patch("tempfile.gettempdir", return_value="/tmp"): + await track_with_normal_path.preprocess() + + # Should fall back to temp directory + assert "streamrip_fallback" in track_with_normal_path.folder + assert track_with_normal_path.download_path # Should be recalculated + + +@pytest.mark.asyncio +async def test_preprocess_fallback_directory_creation() -> None: + """Test that preprocess creates fallback directory when main directory fails.""" + mock_metadata = Mock(spec=TrackMetadata) + mock_metadata.title = "Test Track" + mock_metadata.format_track_path = Mock(return_value="Test Track") + + mock_downloadable = Mock(spec=Downloadable) + mock_downloadable.extension = "flac" + + mock_config = Mock(spec=Config) + mock_config.session = Mock() + mock_config.session.filepaths = Mock() + mock_config.session.filepaths.track_format = "{title}" + mock_config.session.filepaths.restrict_characters = False + mock_config.session.filepaths.truncate_to = 0 + mock_config.session.downloads = Mock() + mock_config.session.downloads.downloads = 3 + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder="/definitely/invalid/path", + cover_path=None, + db=Mock(spec=Database), + ) + + with ( + patch("tempfile.gettempdir", return_value="/tmp"), + patch("os.makedirs") as mock_makedirs, + ): + # First call fails, second succeeds + mock_makedirs.side_effect = [OSError("Permission denied"), None] + + await track.preprocess() + + # Should have switched to fallback folder + assert "streamrip_fallback" in track.folder + + +def test_set_download_path_handles_empty_format() -> None: + """Test _set_download_path handles empty format results.""" + mock_metadata = Mock(spec=TrackMetadata) + mock_metadata.format_track_path = Mock(return_value="") + + mock_downloadable = Mock(spec=Downloadable) + mock_downloadable.extension = "flac" + + mock_config = Mock(spec=Config) + mock_config.session = Mock() + mock_config.session.filepaths = Mock() + mock_config.session.filepaths.restrict_characters = False + mock_config.session.filepaths.truncate_to = 0 + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder="/tmp", + cover_path=None, + db=Mock(spec=Database), + ) + + track._set_download_path() + + # Should still have a valid path with extension + assert track.download_path.endswith(".flac") + assert "/tmp" in track.download_path + + +@pytest.mark.parametrize("extension", ["flac", "mp3", "wav", "m4a"]) +def test_set_download_path_preserves_extension( + track_with_normal_path: Track, extension: str +) -> None: + """Test that _set_download_path preserves the file extension.""" + track_with_normal_path.downloadable.extension = extension + + track_with_normal_path._set_download_path() + + assert track_with_normal_path.download_path.endswith(f".{extension}") + + +def test_set_download_path_strips_trailing_whitespace() -> None: + """Test that _set_download_path strips trailing whitespace after truncation.""" + mock_metadata = Mock(spec=TrackMetadata) + # Create a path that when truncated will have trailing spaces + mock_metadata.format_track_path = Mock(return_value="Track Name With Spaces ") + + mock_downloadable = Mock(spec=Downloadable) + mock_downloadable.extension = "flac" + + mock_config = Mock(spec=Config) + mock_config.session = Mock() + mock_config.session.filepaths = Mock() + mock_config.session.filepaths.restrict_characters = False + mock_config.session.filepaths.truncate_to = 15 # Will truncate and leave spaces + + track = Track( + meta=mock_metadata, + downloadable=mock_downloadable, + config=mock_config, + folder="/tmp", + cover_path=None, + db=Mock(spec=Database), + ) + + track._set_download_path() + + # Should not end with spaces before the extension + filename = os.path.basename(track.download_path) + filename_without_ext = filename.rsplit(".", 1)[0] + assert not filename_without_ext.endswith(" ")