using System.Runtime.CompilerServices; using System.Text; using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.History; using Cleanuparr.Domain.Entities.Arr.ManualImport; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Entities.Radarr; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; using System.Text.Json; using Cleanuparr.Infrastructure.Json; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.Arr; public class RadarrClient : ArrClient, IRadarrClient { public RadarrClient( ILogger logger, IHttpClientFactory httpClientFactory, IStriker striker, IDryRunInterceptor dryRunInterceptor ) : base(logger, httpClientFactory, striker, dryRunInterceptor) { } protected override string ApiVersion => "v3"; protected override string GetSystemStatusUrlPath() { return $"/api/{ApiVersion}/system/status"; } protected override string GetQueueUrlPath() { return $"/api/{ApiVersion}/queue"; } protected override string GetQueueUrlQuery(int page) { return $"page={page}&pageSize={QueuePageSize}&includeUnknownMovieItems=true&includeMovie=true"; } protected override string GetQueueDeleteUrlPath(long recordId) { return $"/api/{ApiVersion}/queue/{recordId}"; } public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { return []; } List ids = items.Select(item => item.Id).ToList(); UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}{CommandUrlPath}"; RadarrCommand command = new() { Name = "MoviesSearch", MovieIds = ids, }; using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri); request.Content = new StringContent( JsonSerializer.Serialize(command, CleanuparrJsonOptions.Outbound), Encoding.UTF8, "application/json" ); SetApiKey(request, arrInstance.ApiKey); string? logContext = await ComputeCommandLogContextAsync(arrInstance, command); try { using HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); if (response is null) { return []; } long? commandId = await ReadCommandIdAsync(response); _logger.LogInformation("{Log}", GetSearchLog(arrInstance.Url, command, true, logContext)); return commandId.HasValue ? [commandId.Value] : []; } catch { _logger.LogError("{Log}", GetSearchLog(arrInstance.Url, command, false, logContext)); throw; } } public override bool HasContentId(QueueRecord record) => record.MovieId is not 0; /// public override bool SupportsForceImport => true; /// public override ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => candidate.ResolvedMovieId is { } movieId && movieId == record.MovieId ? ManualImportFile.FromMovieCandidate(candidate, movieId) : null; private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext) { string status = success ? "triggered" : "failed"; string message = logContext ?? $"movie ids: {string.Join(',', command.MovieIds)}"; return $"movie search {status} | {instanceUrl} | {message}"; } private async Task ComputeCommandLogContextAsync(ArrInstance arrInstance, RadarrCommand command) { try { StringBuilder log = new(); foreach (long movieId in command.MovieIds) { Movie? movie = await GetMovie(arrInstance, movieId); if (movie is null) { return null; } log.Append($"[{movie.Title}]"); } return log.ToString(); } catch (Exception exception) { _logger.LogDebug(exception, "failed to compute log context"); } return null; } public async IAsyncEnumerable StreamAllMoviesAsync( ArrInstance arrInstance, [EnumeratorCancellation] CancellationToken cancellationToken = default) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/movie"; using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); await foreach (SearchableMovie movie in JsonStreamReader.StreamArrayAsync(stream, cancellationToken)) { yield return movie; } } public override async Task> GetAllTagsAsync(ArrInstance arrInstance) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/tag"; using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); return await DeserializeStreamAsync>(response) ?? []; } public async Task> GetQualityProfilesAsync(ArrInstance arrInstance) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/qualityprofile"; using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); return await DeserializeStreamAsync>(response) ?? []; } public async Task> GetMovieFileScoresAsync(ArrInstance arrInstance, List movieFileIds) { Dictionary scores = new(); // Batch in chunks of 100 to avoid 414 URI Too Long foreach (long[] batch in movieFileIds.Chunk(100)) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/moviefile"; uriBuilder.Query = string.Join('&', batch.Select(id => $"movieFileIds={id}")); using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); List files = await DeserializeStreamAsync>(response) ?? []; foreach (MediaFileScore file in files) { scores[file.Id] = file.CustomFormatScore; } } return scores; } /// public async Task> GetRecentlyGrabbedMovieIdsAsync(ArrInstance arrInstance, DateTimeOffset since, CancellationToken cancellationToken = default) { List records = await GetGrabHistorySinceAsync(arrInstance, since, string.Empty, cancellationToken); return records .Where(record => record.MovieId > 0) .Select(record => record.MovieId) .ToHashSet(); } /// public async Task> GetMissingMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) => await GetAllWantedAsync(arrInstance, "missing", monitored, cancellationToken); /// public async Task> GetCutoffUnmetMoviesAsync(ArrInstance arrInstance, bool monitored, CancellationToken cancellationToken = default) => await GetAllWantedAsync(arrInstance, "cutoff", monitored, cancellationToken); private async Task GetMovie(ArrInstance arrInstance, long movieId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/movie/{movieId}"; using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); return await DeserializeStreamAsync(response); } }