diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs
index b93239a..03a3b27 100644
--- a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs
+++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs
@@ -163,3 +163,252 @@ public class DeezerDownloadServiceTests : IDisposable
Assert.Equal("Song not found", exception.Message);
}
}
+
+///
+/// Unit tests for the PathHelper class that handles file organization logic.
+///
+public class PathHelperTests : IDisposable
+{
+ private readonly string _testPath;
+
+ public PathHelperTests()
+ {
+ _testPath = Path.Combine(Path.GetTempPath(), "octo-fiesta-pathhelper-tests-" + Guid.NewGuid());
+ Directory.CreateDirectory(_testPath);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_testPath))
+ {
+ Directory.Delete(_testPath, true);
+ }
+ }
+
+ #region SanitizeFileName Tests
+
+ [Fact]
+ public void SanitizeFileName_WithValidName_ReturnsUnchanged()
+ {
+ // Arrange & Act
+ var result = PathHelper.SanitizeFileName("My Song Title");
+
+ // Assert
+ Assert.Equal("My Song Title", result);
+ }
+
+ [Fact]
+ public void SanitizeFileName_WithInvalidChars_ReplacesWithUnderscore()
+ {
+ // Arrange - Use forward slash which is invalid on all platforms
+ var result = PathHelper.SanitizeFileName("Song/With/Invalid");
+
+ // Assert - Check that forward slashes were replaced with underscores
+ Assert.Equal("Song_With_Invalid", result);
+ }
+
+ [Fact]
+ public void SanitizeFileName_WithNullOrEmpty_ReturnsUnknown()
+ {
+ // Arrange & Act
+ var resultNull = PathHelper.SanitizeFileName(null!);
+ var resultEmpty = PathHelper.SanitizeFileName("");
+ var resultWhitespace = PathHelper.SanitizeFileName(" ");
+
+ // Assert
+ Assert.Equal("Unknown", resultNull);
+ Assert.Equal("Unknown", resultEmpty);
+ Assert.Equal("Unknown", resultWhitespace);
+ }
+
+ [Fact]
+ public void SanitizeFileName_WithLongName_TruncatesTo100Chars()
+ {
+ // Arrange
+ var longName = new string('A', 150);
+
+ // Act
+ var result = PathHelper.SanitizeFileName(longName);
+
+ // Assert
+ Assert.Equal(100, result.Length);
+ }
+
+ #endregion
+
+ #region SanitizeFolderName Tests
+
+ [Fact]
+ public void SanitizeFolderName_WithValidName_ReturnsUnchanged()
+ {
+ // Arrange & Act
+ var result = PathHelper.SanitizeFolderName("Artist Name");
+
+ // Assert
+ Assert.Equal("Artist Name", result);
+ }
+
+ [Fact]
+ public void SanitizeFolderName_WithNullOrEmpty_ReturnsUnknown()
+ {
+ // Arrange & Act
+ var resultNull = PathHelper.SanitizeFolderName(null!);
+ var resultEmpty = PathHelper.SanitizeFolderName("");
+ var resultWhitespace = PathHelper.SanitizeFolderName(" ");
+
+ // Assert
+ Assert.Equal("Unknown", resultNull);
+ Assert.Equal("Unknown", resultEmpty);
+ Assert.Equal("Unknown", resultWhitespace);
+ }
+
+ [Fact]
+ public void SanitizeFolderName_WithTrailingDots_RemovesDots()
+ {
+ // Arrange & Act
+ var result = PathHelper.SanitizeFolderName("Artist Name...");
+
+ // Assert
+ Assert.Equal("Artist Name", result);
+ }
+
+ [Fact]
+ public void SanitizeFolderName_WithInvalidChars_ReplacesWithUnderscore()
+ {
+ // Arrange - Use forward slash which is invalid on all platforms
+ var result = PathHelper.SanitizeFolderName("Artist/With/Invalid");
+
+ // Assert - Check that forward slashes were replaced with underscores
+ Assert.Equal("Artist_With_Invalid", result);
+ }
+
+ #endregion
+
+ #region BuildTrackPath Tests
+
+ [Fact]
+ public void BuildTrackPath_WithAllParameters_CreatesCorrectStructure()
+ {
+ // Arrange
+ var downloadPath = "/downloads";
+ var artist = "Test Artist";
+ var album = "Test Album";
+ var title = "Test Song";
+ var trackNumber = 5;
+ var extension = ".mp3";
+
+ // Act
+ var result = PathHelper.BuildTrackPath(downloadPath, artist, album, title, trackNumber, extension);
+
+ // Assert
+ Assert.Contains("Test Artist", result);
+ Assert.Contains("Test Album", result);
+ Assert.Contains("05 - Test Song.mp3", result);
+ }
+
+ [Fact]
+ public void BuildTrackPath_WithoutTrackNumber_OmitsTrackPrefix()
+ {
+ // Arrange
+ var downloadPath = "/downloads";
+ var artist = "Test Artist";
+ var album = "Test Album";
+ var title = "Test Song";
+ var extension = ".mp3";
+
+ // Act
+ var result = PathHelper.BuildTrackPath(downloadPath, artist, album, title, null, extension);
+
+ // Assert
+ Assert.Contains("Test Song.mp3", result);
+ Assert.DoesNotContain(" - Test Song", result.Split(Path.DirectorySeparatorChar).Last());
+ }
+
+ [Fact]
+ public void BuildTrackPath_WithSingleDigitTrack_PadsWithZero()
+ {
+ // Arrange & Act
+ var result = PathHelper.BuildTrackPath("/downloads", "Artist", "Album", "Song", 3, ".mp3");
+
+ // Assert
+ Assert.Contains("03 - Song.mp3", result);
+ }
+
+ [Fact]
+ public void BuildTrackPath_WithFlacExtension_UsesFlacExtension()
+ {
+ // Arrange & Act
+ var result = PathHelper.BuildTrackPath("/downloads", "Artist", "Album", "Song", 1, ".flac");
+
+ // Assert
+ Assert.EndsWith(".flac", result);
+ }
+
+ [Fact]
+ public void BuildTrackPath_CreatesArtistAlbumHierarchy()
+ {
+ // Arrange & Act
+ var result = PathHelper.BuildTrackPath("/downloads", "My Artist", "My Album", "My Song", 1, ".mp3");
+
+ // Assert
+ // Verify the structure is: downloadPath/Artist/Album/track.mp3
+ var parts = result.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ Assert.Contains("My Artist", parts);
+ Assert.Contains("My Album", parts);
+
+ // Artist should come before Album in the path
+ var artistIndex = Array.IndexOf(parts, "My Artist");
+ var albumIndex = Array.IndexOf(parts, "My Album");
+ Assert.True(artistIndex < albumIndex, "Artist folder should be parent of Album folder");
+ }
+
+ #endregion
+
+ #region ResolveUniquePath Tests
+
+ [Fact]
+ public void ResolveUniquePath_WhenFileDoesNotExist_ReturnsSamePath()
+ {
+ // Arrange
+ var path = Path.Combine(_testPath, "nonexistent.mp3");
+
+ // Act
+ var result = PathHelper.ResolveUniquePath(path);
+
+ // Assert
+ Assert.Equal(path, result);
+ }
+
+ [Fact]
+ public void ResolveUniquePath_WhenFileExists_ReturnsPathWithCounter()
+ {
+ // Arrange
+ var basePath = Path.Combine(_testPath, "existing.mp3");
+ File.WriteAllText(basePath, "content");
+
+ // Act
+ var result = PathHelper.ResolveUniquePath(basePath);
+
+ // Assert
+ Assert.NotEqual(basePath, result);
+ Assert.Contains("existing (1).mp3", result);
+ }
+
+ [Fact]
+ public void ResolveUniquePath_WhenMultipleFilesExist_IncrementsCounter()
+ {
+ // Arrange
+ var basePath = Path.Combine(_testPath, "song.mp3");
+ var path1 = Path.Combine(_testPath, "song (1).mp3");
+ File.WriteAllText(basePath, "content");
+ File.WriteAllText(path1, "content");
+
+ // Act
+ var result = PathHelper.ResolveUniquePath(basePath);
+
+ // Assert
+ Assert.Contains("song (2).mp3", result);
+ }
+
+ #endregion
+}
diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs
index 36f39f8..bbb679c 100644
--- a/octo-fiesta/Services/DeezerDownloadService.cs
+++ b/octo-fiesta/Services/DeezerDownloadService.cs
@@ -367,20 +367,15 @@ public class DeezerDownloadService : IDownloadService
_ => ".mp3"
};
- // Générer le nom de fichier
- var safeTitle = SanitizeFileName(song.Title);
- var safeArtist = SanitizeFileName(song.Artist);
- var fileName = $"{safeArtist} - {safeTitle}{extension}";
- var outputPath = Path.Combine(_downloadPath, fileName);
-
- // Éviter les conflits
- var counter = 1;
- while (File.Exists(outputPath))
- {
- fileName = $"{safeArtist} - {safeTitle} ({counter}){extension}";
- outputPath = Path.Combine(_downloadPath, fileName);
- counter++;
- }
+ // Build organized folder structure: Artist/Album/Track
+ var outputPath = PathHelper.BuildTrackPath(_downloadPath, song.Artist, song.Album, song.Title, song.Track, extension);
+
+ // Create directories if they don't exist
+ var albumFolder = Path.GetDirectoryName(outputPath)!;
+ EnsureDirectoryExists(albumFolder);
+
+ // Resolve unique path if file already exists
+ outputPath = PathHelper.ResolveUniquePath(outputPath);
// Télécharger le fichier chiffré
var response = await RetryWithBackoffAsync(async () =>
@@ -543,8 +538,72 @@ public class DeezerDownloadService : IDownloadService
}
}
- private string SanitizeFileName(string fileName)
+ ///
+ /// Ensures a directory exists, creating it and all parent directories if necessary.
+ /// Handles errors gracefully.
+ ///
+ private void EnsureDirectoryExists(string path)
{
+ try
+ {
+ if (!Directory.Exists(path))
+ {
+ Directory.CreateDirectory(path);
+ _logger.LogDebug("Created directory: {Path}", path);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create directory: {Path}", path);
+ throw;
+ }
+ }
+
+ #endregion
+
+ private class DownloadResult
+ {
+ public string DownloadUrl { get; set; } = string.Empty;
+ public string Format { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Artist { get; set; } = string.Empty;
+ }
+}
+
+///
+/// Helper class for path building and sanitization.
+/// Extracted for testability.
+///
+public static class PathHelper
+{
+ ///
+ /// Builds the output path for a downloaded track following the Artist/Album/Track structure.
+ ///
+ public static string BuildTrackPath(string downloadPath, string artist, string album, string title, int? trackNumber, string extension)
+ {
+ var safeArtist = SanitizeFolderName(artist);
+ var safeAlbum = SanitizeFolderName(album);
+ var safeTitle = SanitizeFileName(title);
+
+ var artistFolder = Path.Combine(downloadPath, safeArtist);
+ var albumFolder = Path.Combine(artistFolder, safeAlbum);
+
+ var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : "";
+ var fileName = $"{trackPrefix}{safeTitle}{extension}";
+
+ return Path.Combine(albumFolder, fileName);
+ }
+
+ ///
+ /// Sanitizes a file name by removing invalid characters.
+ ///
+ public static string SanitizeFileName(string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return "Unknown";
+ }
+
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(fileName
.Select(c => invalidChars.Contains(c) ? '_' : c)
@@ -558,13 +617,65 @@ public class DeezerDownloadService : IDownloadService
return sanitized.Trim();
}
- #endregion
-
- private class DownloadResult
+ ///
+ /// Sanitizes a folder name by removing invalid path characters.
+ /// Similar to SanitizeFileName but also handles additional folder-specific constraints.
+ ///
+ public static string SanitizeFolderName(string folderName)
{
- public string DownloadUrl { get; set; } = string.Empty;
- public string Format { get; set; } = string.Empty;
- public string Title { get; set; } = string.Empty;
- public string Artist { get; set; } = string.Empty;
+ if (string.IsNullOrWhiteSpace(folderName))
+ {
+ return "Unknown";
+ }
+
+ var invalidChars = Path.GetInvalidFileNameChars()
+ .Concat(Path.GetInvalidPathChars())
+ .Distinct()
+ .ToArray();
+
+ var sanitized = new string(folderName
+ .Select(c => invalidChars.Contains(c) ? '_' : c)
+ .ToArray());
+
+ // Remove leading/trailing dots and spaces (Windows folder restrictions)
+ sanitized = sanitized.Trim().TrimEnd('.');
+
+ if (sanitized.Length > 100)
+ {
+ sanitized = sanitized.Substring(0, 100).TrimEnd('.');
+ }
+
+ // Ensure we have a valid name
+ if (string.IsNullOrWhiteSpace(sanitized))
+ {
+ return "Unknown";
+ }
+
+ return sanitized;
+ }
+
+ ///
+ /// Resolves a unique file path by appending a counter if the file already exists.
+ ///
+ public static string ResolveUniquePath(string basePath)
+ {
+ if (!File.Exists(basePath))
+ {
+ return basePath;
+ }
+
+ var directory = Path.GetDirectoryName(basePath)!;
+ var extension = Path.GetExtension(basePath);
+ var fileNameWithoutExt = Path.GetFileNameWithoutExtension(basePath);
+
+ var counter = 1;
+ string uniquePath;
+ do
+ {
+ uniquePath = Path.Combine(directory, $"{fileNameWithoutExt} ({counter}){extension}");
+ counter++;
+ } while (File.Exists(uniquePath));
+
+ return uniquePath;
}
}