Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions listenarr.domain/Configuration/ApplicationSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,5 +237,25 @@ public List<string> ImportBlacklistExtensions
/// Preferred default language filter for Add New searches.
/// </summary>
public string DefaultSearchLanguage { get; set; } = "english";

/// <summary>
/// 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
/// <see cref="AutomaticSearchCooldownHours"/>. Read each cycle, clamped to [1, 168].
/// </summary>
public int AutomaticSearchIntervalHours { get; set; } = 1;

/// <summary>
/// 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].
/// </summary>
public int AutomaticSearchCooldownHours { get; set; } = 24;

/// <summary>
/// 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].
/// </summary>
public int AutomaticSearchBookDelaySeconds { get; set; } = 5;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,40 @@ namespace Listenarr.Infrastructure.HostedServices.Search
public class AutomaticSearchService(
ILogger<AutomaticSearchService> 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<IConfigurationService>();
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
Expand Down Expand Up @@ -75,9 +92,12 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken
var searchService = scope.ServiceProvider.GetRequiredService<ISearchService>();
var qualityProfileService = scope.ServiceProvider.GetRequiredService<IQualityProfileService>();
var downloadService = scope.ServiceProvider.GetRequiredService<IDownloadService>();
var configurationService = scope.ServiceProvider.GetRequiredService<IConfigurationService>();

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);
Expand All @@ -91,13 +111,21 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken
var processedCount = 0;
var downloadsQueued = 0;

var isFirstBook = true;
foreach (var audiobook in monitoredAudiobooks)
{
if (stoppingToken.IsCancellationRequested)
break;

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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

namespace Listenarr.Infrastructure.HostedServices.Search
{
/// <summary>
/// The automatic-search timing knobs resolved from <see cref="ApplicationSettings"/> 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.
/// </summary>
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;

/// <summary>
/// The timing a fresh install gets: hourly sweep, 24-hour cooldown, 5 seconds between books.
/// </summary>
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)));
}

/// <summary>
/// A book whose last search is older than this instant is eligible for the sweep.
/// </summary>
public DateTime EligibilityCutoff(DateTime nowUtc) => nowUtc - BookCooldown;
}
}
Loading
Loading