using System.Text; using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Entities.Sonarr; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Series = Cleanuparr.Domain.Entities.Sonarr.Series; namespace Cleanuparr.Infrastructure.Features.Arr; public class SonarrClient : ArrClient, ISonarrClient { public SonarrClient( ILogger logger, IHttpClientFactory httpClientFactory, IStriker striker, IDryRunInterceptor dryRunInterceptor ) : base(logger, httpClientFactory, striker, dryRunInterceptor) { } protected override string GetSystemStatusUrlPath() { return "/api/v3/system/status"; } protected override string GetQueueUrlPath() { return "/api/v3/queue"; } protected override string GetQueueUrlQuery(int page) { return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true"; } protected override string GetQueueDeleteUrlPath(long recordId) { return $"/api/v3/queue/{recordId}"; } protected override string GetQueueDeleteUrlQuery(bool removeFromClient) { string query = "blocklist=true&skipRedownload=true&changeCategory=false"; query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; return query; } public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { return []; } List commandIds = []; UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command"; foreach (SonarrCommand command in GetSearchCommands(items.Cast().ToHashSet())) { using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri); request.Content = new StringContent( JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), Encoding.UTF8, "application/json" ); SetApiKey(request, arrInstance.ApiKey); string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType); try { HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); if (response is not null) { long? commandId = await ReadCommandIdAsync(response); response.Dispose(); if (commandId.HasValue) { commandIds.Add(commandId.Value); } } _logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext)); } catch { _logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext)); throw; } } return commandIds; } public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0; private static string GetSearchLog( SeriesSearchType searchType, Uri instanceUrl, SonarrCommand command, bool success, string? logContext ) { string status = success ? "triggered" : "failed"; return searchType switch { SeriesSearchType.Episode => $"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}", SeriesSearchType.Season => $"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}", SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}", _ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null) }; } private async Task ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SeriesSearchType searchType) { try { StringBuilder log = new(); if (searchType is SeriesSearchType.Episode) { var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds); if (episodes?.Count is null or 0) { return null; } var seriesIds = episodes .Select(x => x.SeriesId) .Distinct() .ToList(); List series = []; foreach (long id in seriesIds) { Series? show = await GetSeriesAsync(arrInstance, id); if (show is null) { return null; } series.Add(show); } foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId)) { var show = series.First(x => x.Id == group.Key); var episode = episodes .Where(ep => group.Any(x => x == ep.Id)) .OrderBy(x => x.SeasonNumber) .ThenBy(x => x.EpisodeNumber) .Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}") .ToList(); log.Append($"[{show.Title} {string.Join(',', episode)}]"); } } if (searchType is SeriesSearchType.Season) { Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value); if (show is null) { return null; } log.Append($"[{show.Title} season {command.SeasonNumber}]"); } if (searchType is SeriesSearchType.Series) { Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value); if (show is null) { return null; } log.Append($"[{show.Title}]"); } return log.ToString(); } catch (Exception exception) { _logger.LogDebug(exception, "failed to compute log context"); } return null; } public async Task> GetAllSeriesAsync(ArrInstance arrInstance) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series"; using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); using Stream stream = await response.Content.ReadAsStreamAsync(); using StreamReader sr = new(stream); using JsonTextReader reader = new(sr); JsonSerializer serializer = JsonSerializer.CreateDefault(); return serializer.Deserialize>(reader) ?? []; } public async Task> GetEpisodesAsync(ArrInstance arrInstance, long seriesId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode"; uriBuilder.Query = $"seriesId={seriesId}"; 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> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile"; uriBuilder.Query = $"seriesId={seriesId}"; 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/v3/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> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List episodeFileIds) { Dictionary scores = new(); // Batch in chunks of 100 to avoid 414 URI Too Long foreach (long[] batch in episodeFileIds.Chunk(100)) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile"; uriBuilder.Query = string.Join('&', batch.Select(id => $"episodeFileIds={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; } private async Task?> GetEpisodesAsync(ArrInstance arrInstance, List episodeIds) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode"; uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}")); 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); } private async Task GetSeriesAsync(ArrInstance arrInstance, long seriesId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}"; 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); } private List GetSearchCommands(HashSet items) { const string episodeSearch = "EpisodeSearch"; const string seasonSearch = "SeasonSearch"; const string seriesSearch = "SeriesSearch"; List commands = new(); foreach (SeriesSearchItem item in items) { SonarrCommand command = item.SearchType is SeriesSearchType.Episode ? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() } : new(); switch (item.SearchType) { case SeriesSearchType.Episode when command.EpisodeIds is null: command.EpisodeIds = [item.Id]; break; case SeriesSearchType.Episode when command.EpisodeIds is not null: command.EpisodeIds.Add(item.Id); break; case SeriesSearchType.Season: command.Name = seasonSearch; command.SeasonNumber = item.Id; command.SeriesId = ((SeriesSearchItem)item).SeriesId; break; case SeriesSearchType.Series: command.Name = seriesSearch; command.SeriesId = item.Id; break; default: throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null); } if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0) { // only one command will be generated for episodes search continue; } command.SearchType = item.SearchType; commands.Add(command); } return commands; } }