From 8164a6a1d0e96df74f74ee728867f3de624c5a8f Mon Sep 17 00:00:00 2001 From: john Date: Sun, 20 Nov 2022 17:42:53 +1300 Subject: [PATCH] FF-253 - added Create Audio Book node --- AudioNodes/AudioBooks/CreateAudioBook.cs | 196 +++++++++++++++++++++ AudioNodes/AudioInfo.cs | 3 + AudioNodes/AudioNodes.en.json | 7 + AudioNodes/InputNodes/AudioFile.cs | 2 +- AudioNodes/Nodes/AudioFileNormalization.cs | 2 +- AudioNodes/Nodes/AudioNode.cs | 8 +- AudioNodes/Nodes/ConvertNode.cs | 4 +- AudioNodes/Tests/CreateAudioBookTests.cs | 75 ++++++++ 8 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 AudioNodes/AudioBooks/CreateAudioBook.cs create mode 100644 AudioNodes/Tests/CreateAudioBookTests.cs diff --git a/AudioNodes/AudioBooks/CreateAudioBook.cs b/AudioNodes/AudioBooks/CreateAudioBook.cs new file mode 100644 index 00000000..cc0d5feb --- /dev/null +++ b/AudioNodes/AudioBooks/CreateAudioBook.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using FileFlows.Plugin; + +namespace FileFlows.AudioNodes.AudioBooks; + +/// +/// Creates an audio book from audio files in a directory +/// +public class CreateAudioBook: AudioNode +{ + public override string Icon => "fas fa-book"; + + public override int Inputs => 1; + + public override int Outputs => 2; + + public override int Execute(NodeParameters args) + { + var ffmpeg = GetFFmpeg(args); + if (string.IsNullOrEmpty(ffmpeg)) + return -1; + + var dir = args.IsDirectory ? new DirectoryInfo(args.WorkingFile) : new FileInfo(args.WorkingFile).Directory; + + var allowedExtensions = new[] { ".mp3", ".aac", ".m4b", ".m4a" }; + List files = new List(); + foreach (var file in dir.GetFiles("*.*")) + { + if (file.Extension == null) + continue; + if (allowedExtensions.Contains(file.Extension.ToLower()) == false) + continue; + files.Add(file); + } + + if (files.Any() == false) + { + args.Logger.WLog("No audio files found in directory: " + dir.FullName); + return 2; + } + + if (files.Count == 1) + { + args.Logger.WLog("Only one audio file found: " + files[0].FullName); + return 2; + } + + var rgxNumbers = new Regex(@"[\d]+"); + files = files.OrderBy(x => + { + var shortname = x.Name[..^x.Extension.Length]; + var matches = rgxNumbers.Matches(shortname); + if (matches.Any() == false) + { + args.Logger.DLog("No number found in: " + shortname); + return 100000; + } + + if (matches.Count == 1) + { + args.Logger.DLog($"Number [{matches[0].Value}] found in: " + shortname); + return int.Parse(matches[0].Value); + } + + // we may have a year, if first number is 4 digits, assume year + var number = matches[0].Length == 4 ? int.Parse(matches[1].Value) : int.Parse(matches[0].Value); + args.Logger.DLog($"Number [{number}] found in: " + shortname); + return number; + }).ToList(); + + string metadataFile = Path.Combine(args.TempPath, "CreateAudioBookChapters-metadata.txt"); + TimeSpan current = TimeSpan.Zero; + string bookName = dir.Name; + int chapterCount = 1; + File.WriteAllText(metadataFile, @";FFMETADATA1\n\n" + string.Join("\n", files.Select(x => + { + string chapterName = GetChapterName(bookName, x.Name[..^x.Extension.Length], chapterCount); + TimeSpan length = GetChapterLength(args, ffmpeg, x.FullName); + var end = current.Add(length); + var chapter = "[CHAPTER]\n" + + "TIMEBASE=1/1000\n" + + "START=" + (int)(current.TotalMilliseconds + 1500) + "\n" + + "END=" + ((int)end.TotalMilliseconds) + "\n" + + "title=" + chapterName + "\n"; + current = end; + ++chapterCount; + return chapter; + }))); + string inputFiles = Path.Combine(args.TempPath, Guid.NewGuid() + ".txt"); + File.WriteAllText(inputFiles, string.Join("\n", files.Select(x => $"file '{x.FullName}'"))); + + string outputFile = Path.Combine(args.TempPath, Guid.NewGuid() + ".m4b"); + + string artwork = null; //FindArtwork(dir); + + List execArgs = new() { + "-f", + "concat", + "-safe", + "0", + "-i", + inputFiles, + "-i", + metadataFile + }; + + if (string.IsNullOrEmpty(artwork) == false) + { + execArgs.AddRange(new [] + { + "-i", + artwork, + "-map", "0", + "-map_metadata", "1", + "-map", "2", + "-c:v:2", artwork.ToLower().EndsWith("png") ? "png" : "jpg" + }); + } + else + { + execArgs.AddRange(new [] + { + "-map", "0", + "-map_metadata", "1", + }); + } + + execArgs.AddRange(new[] + { + "-vn", // no video + //"-c", "copy", + "-c:a", "aac", + //"-b:a", "128k", + }); + + + if (string.IsNullOrEmpty(artwork) == false) + { + execArgs.AddRange(new [] + { + "-disposition:0", + "attached_pic" + }); + } + + execArgs.Add(outputFile); + + args.Execute(new() + { + Command = ffmpeg, + ArgumentList = execArgs.ToArray() + }); + + if (File.Exists(outputFile) == false || new FileInfo(outputFile).Length == 0) + { + args.Logger.ELog("Failed to create output file: " + outputFile); + return -1; + } + args.Logger.ELog("Created output file: " + outputFile); + return 1; + } + + private string FindArtwork(DirectoryInfo dir) + { + var files = dir.GetFiles("*.*"); + var extensions = new[] { ".png", ".jpg", ".jpe", ".jpeg" }; + foreach(string possible in new [] { "cover", "artwork", "thumbnail", dir.Name.ToLowerInvariant()}) + { + var matching = files.Where(x => x.Name.ToLowerInvariant().StartsWith(possible)).ToArray(); + foreach (var file in matching) + { + if (extensions.Contains(file.Extension.ToLowerInvariant())) + return file.FullName; + } + } + + return string.Empty; + } + + private TimeSpan GetChapterLength(NodeParameters args, string ffmpeg, string filename) + { + var info = new AudioInfoHelper(ffmpeg, args.Logger).Read(filename); + return TimeSpan.FromSeconds(info.Duration); + } + + private string GetChapterName(string bookName, string chapterName, int chapter) + { + chapterName= chapterName.Replace(bookName, string.Empty).Trim(); + string test = chapterName.Replace("0", string.Empty); + test = test.Replace(chapter.ToString(), string.Empty); + test = Regex.Replace(test, "[\"',\\.]", string.Empty); + if(string.IsNullOrEmpty(test)) + return "Chapter " + chapter; + return chapterName; + } +} \ No newline at end of file diff --git a/AudioNodes/AudioInfo.cs b/AudioNodes/AudioInfo.cs index 68c6695a..becafcea 100644 --- a/AudioNodes/AudioInfo.cs +++ b/AudioNodes/AudioInfo.cs @@ -12,6 +12,9 @@ namespace FileFlows.AudioNodes public DateTime Date { get; set; } public string[] Genres { get; set; } public string Encoder { get; set; } + /// + /// Gets or sets duration in SECONDS + /// public long Duration { get; set; } public long Bitrate { get; set; } public string Codec { get; set; } diff --git a/AudioNodes/AudioNodes.en.json b/AudioNodes/AudioNodes.en.json index 5e694530..a27e4e9c 100644 --- a/AudioNodes/AudioNodes.en.json +++ b/AudioNodes/AudioNodes.en.json @@ -79,6 +79,13 @@ "Bitrate": "Bitrate", "Bitrate-Help": "The bitrate for the new WAV file, the higher the bitrate the better the quality but larger the file. 128 Kbps is the recommended rate." } + }, + "CreateAudioBook": { + "Description": "Creates a audio book from audio files found in input directory", + "Outputs": { + "1": "Audio book created", + "2": "Audio book not created" + } } } } diff --git a/AudioNodes/InputNodes/AudioFile.cs b/AudioNodes/InputNodes/AudioFile.cs index 20b85853..46dccc67 100644 --- a/AudioNodes/InputNodes/AudioFile.cs +++ b/AudioNodes/InputNodes/AudioFile.cs @@ -39,7 +39,7 @@ namespace FileFlows.AudioNodes public override int Execute(NodeParameters args) { - string ffmpegExe = GetFFMpegExe(args); + string ffmpegExe = GetFFmpeg(args); if (string.IsNullOrEmpty(ffmpegExe)) return -1; diff --git a/AudioNodes/Nodes/AudioFileNormalization.cs b/AudioNodes/Nodes/AudioFileNormalization.cs index 9ab5355f..2f52c9e9 100644 --- a/AudioNodes/Nodes/AudioFileNormalization.cs +++ b/AudioNodes/Nodes/AudioFileNormalization.cs @@ -20,7 +20,7 @@ public class AudioFileNormalization : AudioNode { try { - string ffmpegExe = GetFFMpegExe(args); + string ffmpegExe = GetFFmpeg(args); if (string.IsNullOrEmpty(ffmpegExe)) return -1; diff --git a/AudioNodes/Nodes/AudioNode.cs b/AudioNodes/Nodes/AudioNode.cs index 1ec47700..26d11609 100644 --- a/AudioNodes/Nodes/AudioNode.cs +++ b/AudioNodes/Nodes/AudioNode.cs @@ -6,18 +6,18 @@ namespace FileFlows.AudioNodes { public override string Icon => "fas fa-music"; - protected string GetFFMpegExe(NodeParameters args) + protected string GetFFmpeg(NodeParameters args) { string ffmpeg = args.GetToolPath("FFMpeg"); if (string.IsNullOrEmpty(ffmpeg)) { - args.Logger.ELog("FFMpeg tool not found."); + args.Logger.ELog("FFmpeg tool not found."); return ""; } var fileInfo = new FileInfo(ffmpeg); if (fileInfo.Exists == false) { - args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist."); + args.Logger.ELog("FFmpeg tool configured by ffmpeg file does not exist."); return ""; } return fileInfo.FullName; @@ -34,7 +34,7 @@ namespace FileFlows.AudioNodes var fileInfo = new FileInfo(ffmpeg); if (fileInfo.Exists == false) { - args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist."); + args.Logger.ELog("FFmpeg tool configured by ffmpeg file does not exist."); return ""; } return fileInfo.DirectoryName; diff --git a/AudioNodes/Nodes/ConvertNode.cs b/AudioNodes/Nodes/ConvertNode.cs index 45d85b4b..981e3e61 100644 --- a/AudioNodes/Nodes/ConvertNode.cs +++ b/AudioNodes/Nodes/ConvertNode.cs @@ -153,7 +153,7 @@ namespace FileFlows.AudioNodes if (AudioInfo == null) return -1; - string ffmpegExe = GetFFMpegExe(args); + string ffmpegExe = GetFFmpeg(args); if (string.IsNullOrEmpty(ffmpegExe)) return -1; @@ -231,7 +231,7 @@ namespace FileFlows.AudioNodes public override int Execute(NodeParameters args) { - string ffmpegExe = GetFFMpegExe(args); + string ffmpegExe = GetFFmpeg(args); if (string.IsNullOrEmpty(ffmpegExe)) return -1; diff --git a/AudioNodes/Tests/CreateAudioBookTests.cs b/AudioNodes/Tests/CreateAudioBookTests.cs new file mode 100644 index 00000000..543f73e2 --- /dev/null +++ b/AudioNodes/Tests/CreateAudioBookTests.cs @@ -0,0 +1,75 @@ +#if(DEBUG) + +using System.Reflection.Metadata; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using FileFlows.AudioNodes.AudioBooks; + +namespace FileFlows.AudioNodes.Tests; + + +[TestClass] +public class CreateAudioBookTests +{ + + [TestMethod] + public void CreateAudioBookTest_01() + { + const string folder = "/home/john/Music/Audio Books/James Dashner (2020) Maze Runner 05.5"; + RunTest(folder); + } + + [TestMethod] + public void CreateAudioBookTest_02() + { + const string folder = @"/home/john/Music/Audio Books/Charlie and the Great Glass Elevator"; + RunTest(folder); + } + [TestMethod] + public void CreateAudioBookTest_03() + { + const string folder = @"/home/john/Music/Audio Books/Scott Westerfeld - Afterworlds"; + RunTest(folder); + } + + [TestMethod] + public void CreateAudioBookTest_04() + { + const string folder = @"/home/john/Music/Audio Books/Small Town-Lawrence Block"; + RunTest(folder); + } + + [TestMethod] + public void CreateAudioBookTest_05() + { + const string folder = @"/home/john/Music/Audio Books/Shatter City"; + RunTest(folder, 2); + } + + [TestMethod] + public void CreateAudioBookTest_06() + { + const string folder = @"/home/john/Music/Audio Books/Among the Betrayed - Margaret Peterson Haddix (M4B)"; + RunTest(folder, 2); + } + + private void RunTest(string folder, int expected = 1) + { + CreateAudioBook node = new (); + var logger = new TestLogger(); + var args = new FileFlows.Plugin.NodeParameters(folder,logger, true, string.Empty); + args.GetToolPathActual = (string tool) => @"/usr/bin/ffmpeg"; + const string tempPath= @"/home/john/Music/test"; + args.TempPath =tempPath ; + foreach (var file in new DirectoryInfo(tempPath).GetFiles( "*.*")) + { + file.Delete(); + } + + int output = node.Execute(args); + + var log = logger.ToString(); + Assert.AreEqual(expected, output); + + } +} +#endif \ No newline at end of file