diff --git a/AudioFileSorter/AudioFileSorter.csproj b/AudioFileSorter/AudioFileSorter.csproj index 624116b..19f314d 100644 --- a/AudioFileSorter/AudioFileSorter.csproj +++ b/AudioFileSorter/AudioFileSorter.csproj @@ -9,7 +9,7 @@ - + diff --git a/AudioFileSorter/CsvParser.cs b/AudioFileSorter/CsvParser.cs index eebb2a4..3ef7fa9 100644 --- a/AudioFileSorter/CsvParser.cs +++ b/AudioFileSorter/CsvParser.cs @@ -3,19 +3,31 @@ using AudioFileSorter.Model; using CsvHelper; using CsvHelper.Configuration; +using System.Linq; + namespace AudioFileSorter; public class CsvParser { - public async Task> ParseDataCsv(string fullPath, CancellationToken token) + public async Task> ParseDataCsv(string? fullPath, CancellationToken token) { - using var reader = new StreamReader(fullPath); + using var reader = new StreamReader(fullPath ?? throw new ArgumentNullException(nameof(fullPath))); using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); try { + var result = new List(); csv.Context.RegisterClassMap(); - return await csv.GetRecordsAsync(token).ToListAsync(token); + await foreach (var openAudible in csv.GetRecordsAsync(token)) + { + result.Add(openAudible); + } + return result; + } + catch (CsvHelperException ex) + { + Console.WriteLine($"CSV Parsing Error: {ex.Message}"); + throw; } finally { diff --git a/AudioFileSorter/Model/AudiobookMap.cs b/AudioFileSorter/Model/AudiobookMap.cs index 81c8b7d..96563df 100644 --- a/AudioFileSorter/Model/AudiobookMap.cs +++ b/AudioFileSorter/Model/AudiobookMap.cs @@ -1,4 +1,5 @@ using CsvHelper.Configuration; +using CsvHelper.TypeConversion; namespace AudioFileSorter.Model; @@ -6,14 +7,19 @@ public sealed class AudiobookMap : ClassMap { public AudiobookMap() { - // Basic mappings - Map(m => m.Key).Name("Key"); + // Mandatory Fields + Map(m => m.Key).Name("Key").Optional(); Map(m => m.Title).Name("Title"); Map(m => m.Author).Name("Author"); + Map(m => m.Filename).Name("File name"); + Map(m => m.FilePaths).Name("File Paths"); + Map(m => m.AudibleAAX).Name("Audible (AAX)").Optional(); + + // Optional Fields Map(m => m.NarratedBy).Name("Narrated By").Optional(); - Map(m => m.PurchaseDate).Name("Purchase Date").TypeConverterOption.Format("yyyy-MM-dd"); + Map(m => m.PurchaseDate).Name("Purchase Date").TypeConverterOption.Format("yyyy-MM-dd", "MM/dd/yyyy", "M/d/yyyy"); Map(m => m.Duration).Name("Duration").Optional(); - Map(m => m.ReleaseDate).Name("Release Date").TypeConverterOption.Format("yyyy-MM-dd"); + Map(m => m.ReleaseDate).Name("Release Date").TypeConverterOption.Format("yyyy-MM-dd", "MM/dd/yyyy", "M/d/yyyy"); Map(m => m.AveRating).Name("Ave. Rating").Optional(); Map(m => m.Genre).Name("Genre").Optional(); Map(m => m.SeriesName).Name("Series Name").Optional(); @@ -23,25 +29,25 @@ public AudiobookMap() Map(m => m.BookURL).Name("Book URL").Optional(); Map(m => m.Summary).Name("Summary").Optional(); Map(m => m.Description).Name("Description").Optional(); - Map(m => m.RatingCount).Name("Rating Count").Optional(); Map(m => m.Publisher).Name("Publisher").Optional(); Map(m => m.ShortTitle).Name("Short Title").Optional(); Map(m => m.Copyright).Name("Copyright").Optional(); Map(m => m.AuthorURL).Name("Author URL").Optional(); - Map(m => m.Filename).Name("File name"); Map(m => m.SeriesURL).Name("Series URL").Optional(); Map(m => m.Abridged).Name("Abridged").Optional(); Map(m => m.Language).Name("Language").Optional(); Map(m => m.PDFURL).Name("PDF URL").Optional(); Map(m => m.ImageURL).Name("Image URL").Optional(); Map(m => m.Region).Name("Region").Optional(); - Map(m => m.FilePaths).Name("File Paths"); - Map(m => m.AYCE).Name("AYCE").Optional(); - Map(m => m.ReadStatus).Name("Read Status"); + Map(m => m.ReadStatus).Name("Read Status").Optional(); Map(m => m.UserID).Name("User ID").Optional(); - Map(m => m.AudibleAAX).Name("Audible (AAX)"); Map(m => m.Image).Name("Image").Optional(); Map(m => m.M4B).Name("M4B").Optional(); Map(m => m.MP3).Name("MP3").Optional(); + Map(m => m.AYCE).Name("AYCE").TypeConverter().Optional(); + Map(m => m.RatingCount).Name("Rating Count").TypeConverter().Optional(); + + // **Newly Added Field** + Map(m => m.PDF).Name("PDF").Optional(); } } \ No newline at end of file diff --git a/AudioFileSorter/Model/OpenAudible.cs b/AudioFileSorter/Model/OpenAudible.cs index 611ce53..2eb48b2 100644 --- a/AudioFileSorter/Model/OpenAudible.cs +++ b/AudioFileSorter/Model/OpenAudible.cs @@ -3,16 +3,16 @@ public class OpenAudible { public string? Key { get; set; } - public string Title { get; set; } - public string Author { get; set; } + public string? Title { get; set; } + public string? Author { get; set; } public string? NarratedBy { get; set; } public DateTime PurchaseDate { get; set; } public string? Duration { get; set; } public DateTime ReleaseDate { get; set; } public double AveRating { get; set; } public string? Genre { get; set; } - public string SeriesName { get; set; } - public string SeriesSequence { get; set; } + public string? SeriesName { get; set; } + public string? SeriesSequence { get; set; } public string? ProductID { get; set; } public string? ASIN { get; set; } public string? BookURL { get; set; } @@ -20,17 +20,17 @@ public class OpenAudible public string? Description { get; set; } public int RatingCount { get; set; } public string? Publisher { get; set; } - public string ShortTitle { get; set; } + public string? ShortTitle { get; set; } public string? Copyright { get; set; } public string? AuthorURL { get; set; } - public string Filename { get; set; } + public string? Filename { get; set; } public string? SeriesURL { get; set; } public string? Abridged { get; set; } public string? Language { get; set; } public string? PDFURL { get; set; } public string? ImageURL { get; set; } public string? Region { get; set; } - public string FilePaths { get; set; } + public string? FilePaths { get; set; } public bool AYCE { get; set; } public string? ReadStatus { get; set; } public string? UserID { get; set; } @@ -38,4 +38,5 @@ public class OpenAudible public string? Image { get; set; } public string? M4B { get; set; } public string? MP3 { get; set; } + public string? PDF { get; set; } } \ No newline at end of file diff --git a/AudioFileSorter/SortAudioFiles.cs b/AudioFileSorter/SortAudioFiles.cs index 08e2959..255466f 100644 --- a/AudioFileSorter/SortAudioFiles.cs +++ b/AudioFileSorter/SortAudioFiles.cs @@ -1,117 +1,212 @@ -using System.Collections; -using AudioFileSorter.Model; -using System.IO; +using System.IO; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using AudioFileSorter.Model; namespace AudioFileSorter; public class FileSorter { + private static readonly object ConsoleLock = new(); /// - /// Sorts Open Audible books into provided destination path + /// Sorts Open Audible books into the provided destination path in parallel. /// - /// - /// - /// - public Task SortAudioFiles(string source, string destination, List openAudibles) + /// Source folder containing audio files. + /// Destination folder to sort files into. + /// List of audiobook metadata. + public async Task SortAudioFiles(string? source, string? destination, List openAudibles) { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(destination)) + { + Console.WriteLine("Error: Source or destination path is missing."); + return; + } + var progressCount = 0; var copyBooks = 0; - openAudibles.ForEach(audioFile => + var totalBooks = openAudibles.Count; + var maxLineLength = 0; + + var maxParallelism = Math.Max(1, Environment.ProcessorCount / 4); // speed up transfers for high end cpus + var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = maxParallelism }; + + + // Run parallel sorting operations + await Parallel.ForEachAsync(openAudibles, parallelOptions, async (audioFile, _) => { - audioFile.Author = SanitizeFileName(audioFile.Author); - audioFile.SeriesName = SanitizeFileName(audioFile.SeriesName); - audioFile.SeriesSequence = SanitizeFileName(audioFile.SeriesSequence); - audioFile.ShortTitle = SanitizeFileName(audioFile.ShortTitle); - audioFile.Title = SanitizeFileName(audioFile.Title); - - if (!string.IsNullOrEmpty(audioFile.Author)) + try { - var authorDirectoryPath = Path.Combine(destination, audioFile.Author); - var directory = Directory.CreateDirectory(authorDirectoryPath); - - if (!string.IsNullOrEmpty(audioFile.SeriesName)) - { - var seriesDirectoryPath = Path.Combine(directory.FullName, audioFile.SeriesName); - directory = Directory.CreateDirectory(seriesDirectoryPath); - - if (!string.IsNullOrEmpty(audioFile.SeriesSequence)) - { - var sequenceDirectoryPath = Path.Combine(directory.FullName, $"Book {audioFile.SeriesSequence}"); - directory = Directory.CreateDirectory(sequenceDirectoryPath); - } - } - - if (!string.IsNullOrEmpty(audioFile.M4B)) - { - var destinationFile = Path.Combine(directory.FullName, audioFile.ShortTitle + ".m4b"); - var sourceFile = Path.Combine(source, audioFile.Filename + ".m4b"); - if (!File.Exists(destinationFile)) - { - File.Copy(sourceFile, destinationFile); - copyBooks += 1; - } - else - { - if (!AreFilesSame(sourceFile, destinationFile)) - { - File.Copy(sourceFile, destinationFile, true); - copyBooks += 1; - } - } - progressCount += 1; - - } - else if (!string.IsNullOrEmpty(audioFile.MP3)) - { - var destinationFile = Path.Combine(directory.FullName, audioFile.ShortTitle + ".mp3"); - var sourceFile = Path.Combine(source, audioFile.Filename + ".mp3"); - if (!File.Exists(destinationFile)) - { - File.Copy(sourceFile, destinationFile); - copyBooks += 1; - } - else - { - if (!AreFilesSame(sourceFile, destinationFile)) - { - File.Copy(sourceFile, destinationFile, true); - copyBooks += 1; - } - } - progressCount += 1; - } - + var copied = await ProcessAudioFile(audioFile, source, destination); + if (copied) Interlocked.Increment(ref copyBooks); } - else + catch (Exception ex) { - Console.WriteLine($"Missing data {audioFile.Filename}"); + Console.WriteLine($"\nError processing {audioFile.Filename}: {ex.Message}"); } - Console.Write($"\n\r{Math.Round((decimal)progressCount/openAudibles.Count*100,2)}/100% ({progressCount} of {openAudibles.Count}) Transferred: {copyBooks} => {audioFile.Title}"); + + var currentProgress = Interlocked.Increment(ref progressCount); + UpdateProgress(currentProgress, totalBooks, copyBooks, audioFile.Title, ref maxLineLength); }); - Console.WriteLine(); - return Task.CompletedTask; + + Console.WriteLine("\nSorting complete."); } - private static string SanitizeFileName(string fileName) + private static async Task ProcessAudioFile(OpenAudible audioFile, string source, string destination) { - var invalidChars = Path.GetInvalidFileNameChars(); - var invalidCharsRemoved = new string(fileName - .Where(ch => !invalidChars.Contains(ch)) - .ToArray()); - invalidCharsRemoved = invalidCharsRemoved.TrimEnd('.', ' '); - return invalidCharsRemoved; + // Sanitize file names to prevent invalid path issues + audioFile.Author = SanitizeFileName(audioFile.Author); + audioFile.SeriesName = SanitizeFileName(audioFile.SeriesName); + audioFile.SeriesSequence = SanitizeFileName(audioFile.SeriesSequence); + audioFile.ShortTitle = SanitizeFileName(audioFile.ShortTitle); + audioFile.Title = SanitizeFileName(audioFile.Title); + + if (string.IsNullOrWhiteSpace(audioFile.Author)) + { + Console.WriteLine($"\nWarning: Missing author for {audioFile.Filename}"); + return false; + } + + var directory = CreateTargetDirectory(destination, audioFile); + return await CopyAudioFileAsync(audioFile, source, directory); } - - private static bool AreFilesSame(string filePath1, string filePath2) + + private static string CreateTargetDirectory(string destination, OpenAudible audioFile) { - var fileInfo1 = new FileInfo(filePath1); - var fileInfo2 = new FileInfo(filePath2); - if (fileInfo1.Length != fileInfo2.Length) + var authorPath = Path.Combine(destination, audioFile.Author ?? throw new InvalidOperationException()); + var directory = Directory.CreateDirectory(authorPath).FullName; + + if (!string.IsNullOrWhiteSpace(audioFile.SeriesName)) { + directory = Path.Combine(directory, audioFile.SeriesName); + Directory.CreateDirectory(directory); + + if (!string.IsNullOrWhiteSpace(audioFile.SeriesSequence)) + { + directory = Path.Combine(directory, $"Book {audioFile.SeriesSequence}"); + Directory.CreateDirectory(directory); + } + } + + return directory; + } + + private static async Task CopyAudioFileAsync(OpenAudible audioFile, string source, string targetDirectory) + { + var fileExtension = GetAudioFileExtension(audioFile); + if (fileExtension == null) return false; + + var sourceFile = Path.Combine(source, $"{audioFile.Filename}{fileExtension}"); + var destinationFile = Path.Combine(targetDirectory, $"{audioFile.ShortTitle}{fileExtension}"); + + if (!File.Exists(sourceFile)) + { + Console.WriteLine($"\nWarning: Source file missing: {sourceFile}"); return false; } - return true; + try + { + if (!File.Exists(destinationFile) || !await AreFilesSameAsync(sourceFile, destinationFile)) + { + await CopyFileAsync(sourceFile, destinationFile); + // File.Copy(sourceFile, destinationFile, true); + return true; + } + } + catch (Exception ex) + { + Console.WriteLine($"\nError copying {sourceFile}: {ex.Message}"); + } + + return false; + } + + private static string? GetAudioFileExtension(OpenAudible audioFile) + { + if (!string.IsNullOrWhiteSpace(audioFile.M4B)) return ".m4b"; + if (!string.IsNullOrWhiteSpace(audioFile.MP3)) return ".mp3"; + return null; + } + + private static string? SanitizeFileName(string? fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) return "Unknown"; + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(fileName.Where(ch => !invalidChars.Contains(ch)).ToArray()); + return sanitized.TrimEnd('.', ' '); + } + + private static async Task AreFilesSameAsync(string filePath1, string filePath2) + { + try + { + var fileInfo1 = new FileInfo(filePath1); + var fileInfo2 = new FileInfo(filePath2); + + // 🔹 Fastest check: Compare file size first + if (fileInfo1.Length != fileInfo2.Length) return false; + + const int chunkSize = 4096; // 4KB buffer for speed + var buffer1 = new byte[chunkSize]; + var buffer2 = new byte[chunkSize]; + + // 🔹 Open files in `FileShare.ReadWrite` mode to prevent locking issues + await using var stream1 = new FileStream(filePath1, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, chunkSize, true); + await using var stream2 = new FileStream(filePath2, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, chunkSize, true); + + // 🔹 Compare first chunk + var bytesRead1 = await stream1.ReadAsync(buffer1, 0, chunkSize); + var bytesRead2 = await stream2.ReadAsync(buffer2, 0, chunkSize); + if (bytesRead1 != bytesRead2 || !buffer1.AsSpan(0, bytesRead1).SequenceEqual(buffer2.AsSpan(0, bytesRead2))) + return false; + + // 🔹 Compare last chunk if file is larger than chunk size + if (fileInfo1.Length > chunkSize) + { + stream1.Seek(-chunkSize, SeekOrigin.End); + stream2.Seek(-chunkSize, SeekOrigin.End); + + bytesRead1 = await stream1.ReadAsync(buffer1, 0, chunkSize); + bytesRead2 = await stream2.ReadAsync(buffer2, 0, chunkSize); + if (bytesRead1 != bytesRead2 || !buffer1.AsSpan(0, bytesRead1).SequenceEqual(buffer2.AsSpan(0, bytesRead2))) + return false; + } + + return true; + } + catch + { + return false; // Assume files are different if any error occurs + } + } + + + private static void UpdateProgress(int currentProgress, int totalBooks, int copyBooks, string? title, ref int maxLineLength) + { + var message = $"{Math.Round((decimal)currentProgress / totalBooks * 100, 2)}% ({currentProgress}/{totalBooks}) Transferred: {copyBooks} => {title}"; + + lock (ConsoleLock) + { + // Clear previous message with spaces to prevent text corruption + Console.Write("\r" + new string(' ', maxLineLength) + "\r"); + Console.Write(message); + + // Track the longest message to properly clear on next update + maxLineLength = Math.Max(maxLineLength, message.Length); + } + } + private static async Task CopyFileAsync(string sourceFile, string destinationFile) + { + const int bufferSize = 81920; // 80KB buffer for efficiency + + await using var sourceStream = new FileStream( + sourceFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + + await using var destinationStream = new FileStream( + destinationFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + + await sourceStream.CopyToAsync(destinationStream); } + } \ No newline at end of file diff --git a/ManagerConsole/ManagerConsole.csproj b/ManagerConsole/ManagerConsole.csproj index 3b29bde..15b7529 100644 --- a/ManagerConsole/ManagerConsole.csproj +++ b/ManagerConsole/ManagerConsole.csproj @@ -6,6 +6,7 @@ enable enable Linux + 2.0.2 diff --git a/ManagerConsole/Program.cs b/ManagerConsole/Program.cs index 80dfe84..7d55e5c 100644 --- a/ManagerConsole/Program.cs +++ b/ManagerConsole/Program.cs @@ -17,7 +17,7 @@ var destinationFolder = Console.ReadLine(); -var bookList = await fileParser.ParseDataCsv(csvFile, new CancellationToken()); +var bookList = await fileParser.ParseDataCsv(csvFile, CancellationToken.None); await fileSorter.SortAudioFiles(sourceFolder, destinationFolder, bookList); Console.WriteLine("Done!"); diff --git a/ManagerService/ManagerService.csproj b/ManagerService/ManagerService.csproj index 753bf3a..66d1ad6 100644 --- a/ManagerService/ManagerService.csproj +++ b/ManagerService/ManagerService.csproj @@ -6,6 +6,7 @@ enable dotnet-AudioBookManager-628A2DA2-30A5-42F9-B725-7275D7F86DCE Linux + 2.0.2 diff --git a/OpenAudibleBookManager.sln b/OpenAudibleBookManager.sln index 72619ee..2584dfa 100644 --- a/OpenAudibleBookManager.sln +++ b/OpenAudibleBookManager.sln @@ -22,6 +22,7 @@ Global {29E18248-3767-4D7A-9A11-8C4FD60BEF65}.Debug|Any CPU.Build.0 = Debug|Any CPU {29E18248-3767-4D7A-9A11-8C4FD60BEF65}.Release|Any CPU.ActiveCfg = Release|Any CPU {29E18248-3767-4D7A-9A11-8C4FD60BEF65}.Release|Any CPU.Build.0 = Release|Any CPU + {29E18248-3767-4D7A-9A11-8C4FD60BEF65}.Release|Any CPU.Deploy.0 = Release|Any CPU {8BF2B66D-32CC-4607-BDBE-356659ED2082}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8BF2B66D-32CC-4607-BDBE-356659ED2082}.Debug|Any CPU.Build.0 = Debug|Any CPU {8BF2B66D-32CC-4607-BDBE-356659ED2082}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -30,5 +31,6 @@ Global {98A32616-C8EE-4A52-8D88-4C08F01F62C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {98A32616-C8EE-4A52-8D88-4C08F01F62C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {98A32616-C8EE-4A52-8D88-4C08F01F62C3}.Release|Any CPU.Build.0 = Release|Any CPU + {98A32616-C8EE-4A52-8D88-4C08F01F62C3}.Release|Any CPU.Deploy.0 = Release|Any CPU EndGlobalSection EndGlobal