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