using ApplicationServices; using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; using Dinah.Core.Net.Http; using LibationFileManager; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; namespace FileLiberator; public interface IProcessable where T : IProcessable { /// /// Create a new instance of the Processable which uses a specific Configuration /// /// The this will use static abstract T Create(Configuration config); } public abstract class Processable { public abstract string Name { get; } public event EventHandler? Begin; /// General string message to display. DON'T rely on this for success, failure, or control logic public event EventHandler? StatusUpdate; /// Fired when a file is successfully saved to disk public event EventHandler<(string id, string path)>? FileCreated; public event EventHandler? StreamingProgressChanged; public event EventHandler? StreamingTimeRemaining; public event EventHandler? Completed; public required Configuration Configuration { get; init; } protected Processable() { } /// True == Valid public abstract bool Validate(LibraryBook libraryBook); /// True == success public abstract Task ProcessAsync(LibraryBook libraryBook); /// /// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking /// for the same license every time. Only the audiobook download does: it is the request Audible refuses, /// and the record gates that same request. /// protected virtual bool RecordsAttemptFailures => false; // when used in foreach: stateful. deferred execution public IEnumerable GetValidLibraryBooks(IEnumerable library) => library.Where(libraryBook => Validate(libraryBook) && (!libraryBook.Book.IsEpisodeChild() || Configuration.DownloadEpisodes) ); public async Task ProcessSingleAsync(LibraryBook libraryBook, bool validate) { if (validate && !Validate(libraryBook)) return new StatusHandler { "Validation failed" }; Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new { libraryBook.Book.TitleWithSubtitle, libraryBook.Book.AudibleProductId, libraryBook.Book.Locale, Account = libraryBook.Account?.ToMask() ?? "[empty]" }); StatusHandler status; try { status = (await ProcessAsync(libraryBook)) ?? new StatusHandler { "Processable should never return a null status" }; } catch (Exception ex) { RecordAttemptFailure(libraryBook, ex); throw; } GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); if (status.IsSuccess && RecordsAttemptFailures) DownloadAttemptFailureStore.Clear(libraryBook); return status; } /// /// Recorded here rather than in each host so the CLI, the GUI queue and anything added later all remember /// a refusal the same way. Failures Libation cannot attribute to Audible are left unrecorded and keep /// being retried on the next run. /// private void RecordAttemptFailure(LibraryBook libraryBook, Exception ex) { if (!RecordsAttemptFailures || !DownloadFailureClassifier.TryClassify(ex, out var diagnosis)) return; DownloadAttemptFailureStore.Record(libraryBook, diagnosis.Kind, diagnosis.Reason); } public async Task TryProcessAsync(LibraryBook libraryBook) => Validate(libraryBook) ? await ProcessAsync(libraryBook) : new StatusHandler(); protected void OnBegin(LibraryBook libraryBook) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = libraryBook.LogFriendly() }); Begin?.Invoke(this, libraryBook); } protected void OnStatusUpdate(string statusUpdate) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StatusUpdate), Status = statusUpdate }); StatusUpdate?.Invoke(this, statusUpdate); } protected void OnFileCreated(LibraryBook libraryBook, string path) { Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path }); FilePathCache.Insert(libraryBook.Book.AudibleProductId, path); FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path)); } protected void OnStreamingProgressChanged(DownloadProgress progress) => OnStreamingProgressChanged(null, progress); protected void OnStreamingProgressChanged(object? _, DownloadProgress progress) => StreamingProgressChanged?.Invoke(this, progress); protected void OnStreamingTimeRemaining(TimeSpan timeRemaining) => OnStreamingTimeRemaining(null, timeRemaining); protected void OnStreamingTimeRemaining(object? _, TimeSpan timeRemaining) => StreamingTimeRemaining?.Invoke(this, timeRemaining); protected void OnCompleted(LibraryBook libraryBook) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() }); Completed?.Invoke(this, libraryBook); } protected void SetFileTime(LibraryBook libraryBook, string file) => setFileSystemTime(libraryBook, new FileInfo(file)); protected void SetDirectoryTime(LibraryBook libraryBook, string file) => setFileSystemTime(libraryBook, new DirectoryInfo(file)); private void setFileSystemTime(LibraryBook libraryBook, FileSystemInfo fileInfo) { if (!fileInfo.Exists) return; DateTime? getTimeValue(Configuration.DateTimeSource source) => source switch { Configuration.DateTimeSource.Added => libraryBook.DateAdded, Configuration.DateTimeSource.Published => libraryBook.Book.DatePublished, _ => null, }; if (getTimeValue(Configuration.CreationTime) is { } creationUtc) { try { fileInfo.CreationTimeUtc = creationUtc; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) { Serilog.Log.Logger.Debug(ex, "Could not set creation time for {Path}; filesystem may not support it.", fileInfo.FullName); } } if (getTimeValue(Configuration.LastWriteTime) is { } lastWriteUtc) { try { fileInfo.LastWriteTimeUtc = lastWriteUtc; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) { Serilog.Log.Logger.Debug(ex, "Could not set last write time for {Path}; filesystem may not support it.", fileInfo.FullName); } } } }