using ApplicationServices; using LibationFileManager; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; namespace LibationCli; /// /// How much a single liberate run may download, from the mutually exclusive --limit-books, /// --limit-mb and --limit-gb options. Distinct from the daily download limit, which is a setting /// spanning a rolling 24 hours and keeps applying on top of this one. /// public readonly record struct RunDownloadLimit(Configuration.DailyLimitUnit Unit, int Quantity) { public string Describe() => Unit is Configuration.DailyLimitUnit.Books ? $"{Quantity} book(s)" : $"{Quantity} {Unit}"; public static string OptionName(Configuration.DailyLimitUnit unit) => unit switch { Configuration.DailyLimitUnit.MB => "--limit-mb", Configuration.DailyLimitUnit.GB => "--limit-gb", _ => "--limit-books" }; /// /// Builds the limit from the three options, at most one of which the parser lets through. Passing none is /// success with a null : an unlimited run is the default, not an error. /// public static bool TryCreate( int? books, int? megabytes, int? gigabytes, bool pdfOnly, out RunDownloadLimit? limit, [NotNullWhen(false)] out string? error) { limit = null; error = null; (Configuration.DailyLimitUnit Unit, int Quantity)? specified = books is int b ? (Configuration.DailyLimitUnit.Books, b) : megabytes is int mb ? (Configuration.DailyLimitUnit.MB, mb) : gigabytes is int gb ? (Configuration.DailyLimitUnit.GB, gb) : null; if (specified is null) return true; var (unit, quantity) = specified.Value; if (quantity < 1) { error = $"{OptionName(unit)} must be at least 1."; return false; } // Pdf downloads are never recorded, so a limit combined with --pdf would silently never stop the run. if (pdfOnly) { error = $"{OptionName(unit)} cannot be used with --pdf. The limit counts audiobook downloads, and --pdf downloads no audiobooks."; return false; } limit = new RunDownloadLimit(unit, quantity); return true; } } /// /// Tracks how much one run has downloaded and decides when it has had enough. /// /// Counts the same downloads the daily limit counts, by reading the history rows written when a download /// succeeds, so failed, cancelled and pdf-only work is never counted and the two limits cannot disagree about /// what a book or a byte is worth. Only titles this run attempted are counted, so a Libation window or a second /// container downloading at the same time does not consume this run's allowance. /// /// internal sealed class RunLimitTracker { private const string StopSuffix = "Remaining titles are still un-liberated and will be tried on the next run."; private readonly RunDownloadLimit limit; private readonly DateTimeOffset runStart; private readonly Func> readHistory; private readonly HashSet attempted = new(StringComparer.OrdinalIgnoreCase); public RunLimitTracker( RunDownloadLimit limit, DateTimeOffset runStart, Func>? readHistory = null) { this.limit = limit; this.runStart = runStart; this.readHistory = readHistory ?? DownloadHistoryStore.GetSince; } /// Call for every title handed to the processable, whether or not it ends up downloading. public void Attempting(string? audibleProductId) { if (!string.IsNullOrWhiteSpace(audibleProductId)) attempted.Add(audibleProductId); } /// /// True when the run has downloaded all it may. Re-reads history on every call, so it reflects whatever the /// last title turned out to weigh rather than an estimate. /// public bool TryStop([NotNullWhen(true)] out string? message) { var (books, bytes) = Downloaded(); // One download is always allowed: a limit smaller than a single estimated book would otherwise end the // run immediately and report a limit reached to someone who had downloaded nothing. if (books == 0 || DailyDownloadLimit.FitsAnother(limit.Unit, limit.Quantity, books, bytes)) { message = null; return false; } message = limit.Unit is Configuration.DailyLimitUnit.Books ? $"Reached this run's limit of {limit.Describe()}. Downloaded {books} title(s); stopping. {StopSuffix}" : $"Reached this run's limit of {limit.Describe()}. Downloaded about {DiskSpaceHelper.FormatBytes(bytes)} across {books} title(s); stopping. {StopSuffix}"; return true; } private (int Books, long Bytes) Downloaded() { if (attempted.Count == 0) return (0, 0); var mine = readHistory(runStart) .Where(e => e.AudibleProductId is string id && attempted.Contains(id)) .ToList(); return (mine.Count, mine.Sum(e => e.Bytes)); } }