diff --git a/listenarr.domain/Configuration/ApplicationSettings.cs b/listenarr.domain/Configuration/ApplicationSettings.cs index 61d65cb789..e461d9371c 100644 --- a/listenarr.domain/Configuration/ApplicationSettings.cs +++ b/listenarr.domain/Configuration/ApplicationSettings.cs @@ -237,5 +237,25 @@ public List ImportBlacklistExtensions /// Preferred default language filter for Add New searches. /// public string DefaultSearchLanguage { get; set; } = "english"; + + /// + /// How often the automatic-search worker sweeps for eligible books, in hours. + /// This is only the cadence of the check; which books it picks up is decided by + /// . Read each cycle, clamped to [1, 168]. + /// + public int AutomaticSearchIntervalHours { get; set; } = 1; + + /// + /// How long a book stays ineligible for automatic search after it was last searched, + /// in hours. One shared cooldown for every book. Clamped to [1, 720]. + /// + public int AutomaticSearchCooldownHours { get; set; } = 24; + + /// + /// Pause between consecutive book searches inside one sweep, in seconds, so a large + /// wanted list does not hammer the indexers. 0 disables it. Manual "Search now" is + /// unaffected. Clamped to [0, 300]. + /// + public int AutomaticSearchBookDelaySeconds { get; set; } = 5; } } diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs index 0c160ca180..9c24232dc9 100644 --- a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs @@ -24,23 +24,40 @@ namespace Listenarr.Infrastructure.HostedServices.Search public class AutomaticSearchService( ILogger logger, IAutomaticSearchProcessor processor, - IWorkerCycleRunner cycleRunner) : BackgroundService + IWorkerCycleRunner cycleRunner, + IServiceScopeFactory scopeFactory) : BackgroundService { - private static readonly TimeSpan SearchInterval = TimeSpan.FromHours(6); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - logger.LogInformation("AutomaticSearchService started. Will search monitored audiobooks every {Hours} hours", SearchInterval.TotalHours); + logger.LogInformation("AutomaticSearchService started. Sweep interval and per-book cooldown are read from settings each cycle"); await cycleRunner.RunPeriodicAsync( nameof(AutomaticSearchService), initialDelay: TimeSpan.FromMinutes(5), - intervalProvider: () => SearchInterval, + intervalProvider: GetSweepInterval, runCycle: processor.RunCycleAsync, stoppingToken); logger.LogInformation("AutomaticSearchService stopped"); } + + // Read each cycle so a settings change takes effect without a restart. The sweep + // cadence only decides how often the worker looks; eligibility is the cooldown's job. + private TimeSpan GetSweepInterval() + { + try + { + using var scope = scopeFactory.CreateScope(); + var configurationService = scope.ServiceProvider.GetRequiredService(); + var settings = configurationService.GetApplicationSettingsAsync().GetAwaiter().GetResult(); + return AutomaticSearchTiming.FromSettings(settings).SweepInterval; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Could not read automatic-search timing settings; sweeping every {Hours} hour(s)", AutomaticSearchTiming.Default.SweepInterval.TotalHours); + return AutomaticSearchTiming.Default.SweepInterval; + } + } } public class AutomaticSearchProcessor : IAutomaticSearchProcessor @@ -75,9 +92,12 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken var searchService = scope.ServiceProvider.GetRequiredService(); var qualityProfileService = scope.ServiceProvider.GetRequiredService(); var downloadService = scope.ServiceProvider.GetRequiredService(); + var configurationService = scope.ServiceProvider.GetRequiredService(); + + var timing = AutomaticSearchTiming.FromSettings(await configurationService.GetApplicationSettingsAsync()); - // Get all monitored audiobooks that haven't been searched in the last 6 hours - var cutoffTime = DateTime.UtcNow.AddHours(-6); + // A book is eligible once per cooldown, however often the sweep itself runs. + var cutoffTime = timing.EligibilityCutoff(DateTime.UtcNow); var monitoredAudiobooks = await audiobookRepository.GetMonitoredAudiobooksForSearchAsync(cutoffTime, stoppingToken); _logger.LogInformation("Found {Count} monitored audiobooks eligible for automatic search", monitoredAudiobooks.Count); @@ -91,6 +111,7 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken var processedCount = 0; var downloadsQueued = 0; + var isFirstBook = true; foreach (var audiobook in monitoredAudiobooks) { if (stoppingToken.IsCancellationRequested) @@ -98,6 +119,13 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken try { + // Pause between books so a large wanted list does not hammer the indexers. + if (!isFirstBook && timing.BookDelay > TimeSpan.Zero) + { + await Task.Delay(timing.BookDelay, stoppingToken); + } + isFirstBook = false; + var downloadsQueuedForBook = await ProcessAudiobookAsync( audiobook, searchService, qualityProfileService, downloadService, audiobookRepository, downloadRepository, fileRepository, stoppingToken); diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchTiming.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchTiming.cs new file mode 100644 index 0000000000..f7713741a6 --- /dev/null +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchTiming.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.HostedServices.Search +{ + /// + /// The automatic-search timing knobs resolved from with + /// their clamps applied. The three are independent: the sweep interval is how often the + /// worker looks for work, the cooldown is how long a book stays ineligible after a search, + /// and the book delay is the pause between searches inside one sweep. A short sweep with a + /// long cooldown picks books up promptly once they become eligible without searching any + /// of them more than once per cooldown. + /// + public sealed record AutomaticSearchTiming( + TimeSpan SweepInterval, + TimeSpan BookCooldown, + TimeSpan BookDelay) + { + public const int MinSweepIntervalHours = 1; + public const int MaxSweepIntervalHours = 168; + public const int MinCooldownHours = 1; + public const int MaxCooldownHours = 720; + public const int MinBookDelaySeconds = 0; + public const int MaxBookDelaySeconds = 300; + + /// + /// The timing a fresh install gets: hourly sweep, 24-hour cooldown, 5 seconds between books. + /// + public static AutomaticSearchTiming Default { get; } = FromSettings(new ApplicationSettings()); + + public static AutomaticSearchTiming FromSettings(ApplicationSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + return new AutomaticSearchTiming( + TimeSpan.FromHours(Math.Clamp( + settings.AutomaticSearchIntervalHours, + MinSweepIntervalHours, + MaxSweepIntervalHours)), + TimeSpan.FromHours(Math.Clamp( + settings.AutomaticSearchCooldownHours, + MinCooldownHours, + MaxCooldownHours)), + TimeSpan.FromSeconds(Math.Clamp( + settings.AutomaticSearchBookDelaySeconds, + MinBookDelaySeconds, + MaxBookDelaySeconds))); + } + + /// + /// A book whose last search is older than this instant is eligible for the sweep. + /// + public DateTime EligibilityCutoff(DateTime nowUtc) => nowUtc - BookCooldown; + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.Designer.cs new file mode 100644 index 0000000000..b1b4a8e500 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.Designer.cs @@ -0,0 +1,2861 @@ +// +using System; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ListenArrDbContext))] + [Migration("20261021212418_AddAutomaticSearchTiming")] + partial class AddAutomaticSearchTiming + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.DownloadHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClient") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WasImported") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventDate"); + + b.HasIndex("DownloadId", "EventType"); + + b.ToTable("DownloadHistories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.History", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookExternalId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookTitle") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("ParentEventId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceTitle") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookExternalId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("\"IdempotencyKey\" IS NOT NULL"); + + b.HasIndex("Outcome"); + + b.HasIndex("Timestamp"); + + b.ToTable("History"); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookDeletionIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFolder") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId") + .IsUnique() + .HasFilter("\"State\" <> 'Completed'"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("AudiobookId", "State"); + + b.ToTable("AudiobookDeletionIntents", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("PathIdentityBoundary") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathIdentityReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("PathIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityObservedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationOperationId") + .HasColumnType("TEXT"); + + b.Property("CreationWorkflow") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("ManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StateReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ManagedRootFolderId"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.HasIndex("CreationOperationId", "State"); + + b.ToTable("LibraryDirectoryOwnerships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("SourceCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourceOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId"); + + b.HasIndex("TargetOwnershipKey") + .IsUnique(); + + b.HasIndex("OwnershipId", "RelocationId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FailureKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("ForceCopyAndRetainSource") + .HasColumnType("INTEGER"); + + b.Property("IdentityKeyVersion") + .HasColumnType("INTEGER"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.Property("SourceDirectoryCleanupState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Pending"); + + b.Property("SourceDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("TargetRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("TargetStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("RelocationId"); + + b.HasIndex("AudiobookId", "Status"); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "Path") + .IsUnique(); + + b.ToTable("MoveJobCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleanupProtectionVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("CleanupState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CopyState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "RelativePath") + .IsUnique(); + + b.ToTable("MoveJobEntries", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveScanJobId") + .HasColumnType("TEXT"); + + b.Property("AttemptGeneration") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .HasColumnType("INTEGER"); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveScanHandoffs", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PathIdentityKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("StorageContractRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeakStoragePolicyRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("WeakStorageSourceCleanupPolicy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault") + .IsUnique() + .HasDatabaseName("IX_RootFolders_SingleDefault") + .HasFilter("\"IsDefault\" = 1"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.HasIndex("PathIdentityKey") + .IsUnique() + .HasFilter("\"PathIdentityKey\" IS NOT NULL"); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CompletedJobs") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("DesiredIsDefault") + .HasColumnType("INTEGER"); + + b.Property("DesiredName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("RootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("TargetIdentityEnrollmentState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Authorized"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TotalJobs") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRootFolderId") + .IsUnique() + .HasFilter("\"ActiveRootFolderId\" IS NOT NULL"); + + b.HasIndex("RootFolderId"); + + b.ToTable("RootFolderRelocations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("RelocationId", "CanonicalPath") + .IsUnique(); + + b.ToTable("RootFolderRelocationCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId", "AudiobookId") + .IsUnique(); + + b.ToTable("RootFolderRelocationSkippedItems", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.WeakStorageScanCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("ConfirmedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ExpectedResolvedPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpectedStoredPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ScanToken") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ScanToken"); + + b.HasIndex("AudiobookId", "ConfirmedAt", "ExpiresAt"); + + b.ToTable("WeakStorageScanCandidates", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AutomaticSearchBookDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("AutomaticSearchCooldownHours") + .HasColumnType("INTEGER"); + + b.Property("AutomaticSearchIntervalHours") + .HasColumnType("INTEGER"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.CompatibilityFilePublicationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CleanupOwner") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("EffectiveAction") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ExpectedBatchSourceManifestSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsCompanionFile") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("QuarantinePath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RequestedAction") + .HasColumnType("INTEGER"); + + b.Property("SourceDisposition") + .HasColumnType("INTEGER"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TargetLength") + .HasColumnType("INTEGER"); + + b.Property("TargetSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("CompatibilityFilePublicationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.FileMutationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourceParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("State"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("FileMutationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.VerifiedFileRenameJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchManifestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("RetirementPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("StagingPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("VerifiedFileRenameJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", null) + .WithMany() + .HasForeignKey("ManagedRootFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithMany("PathMigrations") + .HasForeignKey("OwnershipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("OwnershipPathMigrations") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ownership"); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("MoveJobs") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("CreatedDirectories") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("Entries") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithOne("ScanHandoff") + .HasForeignKey("Listenarr.Domain.Audiobooks.MoveScanHandoff", "MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") + .WithMany("Relocations") + .HasForeignKey("RootFolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RootFolder"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("CreatedDirectories") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("SkippedItems") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Navigation("PathMigrations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("Entries"); + + b.Navigation("ScanHandoff"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Navigation("Relocations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("MoveJobs"); + + b.Navigation("OwnershipPathMigrations"); + + b.Navigation("SkippedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.cs b/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.cs new file mode 100644 index 0000000000..0c3dacdd01 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20261021212418_AddAutomaticSearchTiming.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAutomaticSearchTiming : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Column defaults match the ApplicationSettings property initializers so the + // existing singleton settings row behaves exactly like a fresh install. + migrationBuilder.AddColumn( + name: "AutomaticSearchBookDelaySeconds", + table: "ApplicationSettings", + type: "INTEGER", + nullable: false, + defaultValue: 5); + + migrationBuilder.AddColumn( + name: "AutomaticSearchCooldownHours", + table: "ApplicationSettings", + type: "INTEGER", + nullable: false, + defaultValue: 24); + + migrationBuilder.AddColumn( + name: "AutomaticSearchIntervalHours", + table: "ApplicationSettings", + type: "INTEGER", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AutomaticSearchBookDelaySeconds", + table: "ApplicationSettings"); + + migrationBuilder.DropColumn( + name: "AutomaticSearchCooldownHours", + table: "ApplicationSettings"); + + migrationBuilder.DropColumn( + name: "AutomaticSearchIntervalHours", + table: "ApplicationSettings"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index ec1b0b41bd..6ad1e2cdd0 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -1807,6 +1807,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("AutomaticSearchBookDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("AutomaticSearchCooldownHours") + .HasColumnType("INTEGER"); + + b.Property("AutomaticSearchIntervalHours") + .HasColumnType("INTEGER"); + b.Property("CompletedFileAction") .HasColumnType("INTEGER"); diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 8203765a30..ccb3653237 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -248,6 +248,38 @@ public void FinalMoveMigration_TargetModelMatchesFinalContracts() "Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker")); } + [Fact] + public void AddAutomaticSearchTiming_IsDiscoverableAndAddsSettingsColumnsWithDefaults() + { + AssertMigrationId( + "20261021212418_AddAutomaticSearchTiming"); + + var migration = new AddAutomaticSearchTiming(); + var up = BuildOperations(migration, "Up"); + var down = BuildOperations(migration, "Down"); + + var columns = up.Operations.OfType().ToList(); + Assert.Equal(3, up.Operations.Count); + Assert.Equal(3, columns.Count); + Assert.All(columns, column => + { + Assert.Equal("ApplicationSettings", column.Table); + Assert.False(column.IsNullable); + }); + Assert.Equal(1, columns.Single(column => column.Name == "AutomaticSearchIntervalHours").DefaultValue); + Assert.Equal(24, columns.Single(column => column.Name == "AutomaticSearchCooldownHours").DefaultValue); + Assert.Equal(5, columns.Single(column => column.Name == "AutomaticSearchBookDelaySeconds").DefaultValue); + Assert.Equal(3, down.Operations.OfType().Count()); + Assert.Equal(3, down.Operations.Count); + + var applicationSettings = AssertEntity( + migration.TargetModel, + "Listenarr.Domain.Configuration.ApplicationSettings"); + Assert.NotNull(applicationSettings.FindProperty("AutomaticSearchIntervalHours")); + Assert.NotNull(applicationSettings.FindProperty("AutomaticSearchCooldownHours")); + Assert.NotNull(applicationSettings.FindProperty("AutomaticSearchBookDelaySeconds")); + } + private static MigrationBuilder BuildOperations(Migration migration, string methodName) { var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.Sqlite"); diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index fdadc41ee8..b06337c217 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -44,6 +44,8 @@ public class SqliteMigrationSchemaTests : BaseTests "20260825021432_AddWeakStorageVerifiedCleanup"; private const string OperationEvidencePublicationsMigrationId = "20261002210137_AddOperationEvidencePublications"; + private const string AutomaticSearchTimingMigrationId = + "20261021212418_AddAutomaticSearchTiming"; private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() @@ -226,7 +228,8 @@ public async Task MigrationHistory_ContainsOnlyRetainedRepairsAndConsolidatedPrM FileMutationParentGenerationProofsMigrationId, CompatibilityFilePublicationMigrationId, WeakStorageVerifiedCleanupMigrationId, - OperationEvidencePublicationsMigrationId + OperationEvidencePublicationsMigrationId, + AutomaticSearchTimingMigrationId ], postCanary); Assert.Contains("20251124102000_AddMoveJobSourcePath", applied); diff --git a/tests/Features/Infrastructure/Search/AutomaticSearchTimingTests.cs b/tests/Features/Infrastructure/Search/AutomaticSearchTimingTests.cs new file mode 100644 index 0000000000..b84a189c3a --- /dev/null +++ b/tests/Features/Infrastructure/Search/AutomaticSearchTimingTests.cs @@ -0,0 +1,98 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Search; + +[Trait("Name", "AutomaticSearchTimingTests")] +[Trait("Category", "Infrastructure")] +public class AutomaticSearchTimingTests : BaseTests +{ + [Fact] + public void Defaults_SweepHourly_CooldownDaily_FiveSecondsBetweenBooks() + { + var timing = AutomaticSearchTiming.FromSettings(new ApplicationSettings()); + + Assert.Equal(TimeSpan.FromHours(1), timing.SweepInterval); + Assert.Equal(TimeSpan.FromHours(24), timing.BookCooldown); + Assert.Equal(TimeSpan.FromSeconds(5), timing.BookDelay); + Assert.Equal(timing, AutomaticSearchTiming.Default); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(6, 6)] + [InlineData(168, 168)] + [InlineData(5000, 168)] + public void SweepInterval_IsClampedToOneHourThroughOneWeek(int configuredHours, int expectedHours) + { + var timing = AutomaticSearchTiming.FromSettings( + new ApplicationSettings { AutomaticSearchIntervalHours = configuredHours }); + + Assert.Equal(TimeSpan.FromHours(expectedHours), timing.SweepInterval); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(48, 48)] + [InlineData(720, 720)] + [InlineData(99999, 720)] + public void Cooldown_IsClampedToOneHourThroughThirtyDays(int configuredHours, int expectedHours) + { + var timing = AutomaticSearchTiming.FromSettings( + new ApplicationSettings { AutomaticSearchCooldownHours = configuredHours }); + + Assert.Equal(TimeSpan.FromHours(expectedHours), timing.BookCooldown); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(30, 30)] + [InlineData(301, 300)] + public void BookDelay_IsClampedToZeroThroughFiveMinutes(int configuredSeconds, int expectedSeconds) + { + var timing = AutomaticSearchTiming.FromSettings( + new ApplicationSettings { AutomaticSearchBookDelaySeconds = configuredSeconds }); + + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timing.BookDelay); + } + + [Fact] + public void EligibilityCutoff_FollowsTheCooldown_NotTheSweepInterval() + { + var now = new DateTime(2026, 10, 21, 12, 0, 0, DateTimeKind.Utc); + var timing = AutomaticSearchTiming.FromSettings(new ApplicationSettings + { + AutomaticSearchIntervalHours = 1, + AutomaticSearchCooldownHours = 24 + }); + + var cutoff = timing.EligibilityCutoff(now); + + Assert.Equal(now.AddHours(-24), cutoff); + Assert.Equal(DateTimeKind.Utc, cutoff.Kind); + } + + [Fact] + public void FromSettings_RejectsNull() + { + Assert.Throws(() => AutomaticSearchTiming.FromSettings(null!)); + } +}