mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: improve downloaded files organization
This commit is contained in:
@@ -163,3 +163,252 @@ public class DeezerDownloadServiceTests : IDisposable
|
|||||||
Assert.Equal("Song not found", exception.Message);
|
Assert.Equal("Song not found", exception.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the PathHelper class that handles file organization logic.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,20 +367,15 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
_ => ".mp3"
|
_ => ".mp3"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Générer le nom de fichier
|
// Build organized folder structure: Artist/Album/Track
|
||||||
var safeTitle = SanitizeFileName(song.Title);
|
var outputPath = PathHelper.BuildTrackPath(_downloadPath, song.Artist, song.Album, song.Title, song.Track, extension);
|
||||||
var safeArtist = SanitizeFileName(song.Artist);
|
|
||||||
var fileName = $"{safeArtist} - {safeTitle}{extension}";
|
// Create directories if they don't exist
|
||||||
var outputPath = Path.Combine(_downloadPath, fileName);
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
EnsureDirectoryExists(albumFolder);
|
||||||
// Éviter les conflits
|
|
||||||
var counter = 1;
|
// Resolve unique path if file already exists
|
||||||
while (File.Exists(outputPath))
|
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||||
{
|
|
||||||
fileName = $"{safeArtist} - {safeTitle} ({counter}){extension}";
|
|
||||||
outputPath = Path.Combine(_downloadPath, fileName);
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Télécharger le fichier chiffré
|
// Télécharger le fichier chiffré
|
||||||
var response = await RetryWithBackoffAsync(async () =>
|
var response = await RetryWithBackoffAsync(async () =>
|
||||||
@@ -543,8 +538,72 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SanitizeFileName(string fileName)
|
/// <summary>
|
||||||
|
/// Ensures a directory exists, creating it and all parent directories if necessary.
|
||||||
|
/// Handles errors gracefully.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for path building and sanitization.
|
||||||
|
/// Extracted for testability.
|
||||||
|
/// </summary>
|
||||||
|
public static class PathHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the output path for a downloaded track following the Artist/Album/Track structure.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitizes a file name by removing invalid characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string SanitizeFileName(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
var sanitized = new string(fileName
|
var sanitized = new string(fileName
|
||||||
.Select(c => invalidChars.Contains(c) ? '_' : c)
|
.Select(c => invalidChars.Contains(c) ? '_' : c)
|
||||||
@@ -558,13 +617,65 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
return sanitized.Trim();
|
return sanitized.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
/// <summary>
|
||||||
|
/// Sanitizes a folder name by removing invalid path characters.
|
||||||
private class DownloadResult
|
/// Similar to SanitizeFileName but also handles additional folder-specific constraints.
|
||||||
|
/// </summary>
|
||||||
|
public static string SanitizeFolderName(string folderName)
|
||||||
{
|
{
|
||||||
public string DownloadUrl { get; set; } = string.Empty;
|
if (string.IsNullOrWhiteSpace(folderName))
|
||||||
public string Format { get; set; } = string.Empty;
|
{
|
||||||
public string Title { get; set; } = string.Empty;
|
return "Unknown";
|
||||||
public string Artist { get; set; } = string.Empty;
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a unique file path by appending a counter if the file already exists.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user