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; } }