Skip to content

Commit

Permalink
feat: add an hostedService to full libraries and qulifying it
Browse files Browse the repository at this point in the history
  • Loading branch information
Fazzani committed Dec 28, 2024
1 parent 0400260 commit f7e7684
Show file tree
Hide file tree
Showing 16 changed files with 317 additions and 24 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app

ENV TZ="Europe/London"
ENV ASPNETCORE_HTTP_PORTS=8880

EXPOSE $ASPNETCORE_HTTP_PORTS
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ It uses TMDB to find out which streaming services are available in the selected
2. On your Radarr/Sonarr instances we have to do some changes
- tag all indexers by the TAG_NAME defined in your [config.yml][config-yml] (`q` by default)<br/>
<img src="images/tagging-indexers.png" width="250" alt="tag indexers" />
- specify Application URL: is essential because it is used by Proxarr to determine which instance should return the response<br/>
- specify Application URL: is essential because it is used by Proxarr to determine to which instance should return the response<br/>
<img src="images/application_url.png" width="250" alt="Application Url config"/>
- establish a Webhook connection between Sonarr/Radarr and Proxarr<br/>
<img src="images/webhook_config.png" width="250" alt="Application Url config"/><br/>
Expand All @@ -99,6 +99,7 @@ It uses TMDB to find out which streaming services are available in the selected
- "8880:8880"
environment:
- LOG_LEVEL=Information
- TZ="Europe/Paris"
volumes:
- ./:/app/config
- ./logs:/logs"
Expand All @@ -125,7 +126,6 @@ docker run -itd --rm -e LOG_LEVEL=Debug -p 8880:8880 -v ${PWD}/config:/app/confi
<!-- ROADMAP -->
## Roadmap

- [ ] Add Full scan library feature
- [ ] Add more providers (JustWatch, Reelgood, etc)
- [ ] Add more tests
- [ ] Improve logging and error handling
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ services:
- "8880:8880"
environment:
- LOG_LEVEL=Information
- TZ="Europe/Paris"
volumes:
- ./:/app/config
- ./logs:/logs"
Expand Down
4 changes: 4 additions & 0 deletions src/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ dotnet_style_namespace_match_folder = true:suggestion
dotnet_diagnostic.CA1304.severity = error
dotnet_diagnostic.CA1305.severity = error
[*]
vsspell_section_id = 25685bb86f6248d2844f68d46e45a9b5
vsspell_ignored_words_25685bb86f6248d2844f68d46e45a9b5 = cron

[[*.gitignore](gitignore)]
# VSSPELL: gitignore
[[*.http](http file)]
[Dockerfile]
30 changes: 24 additions & 6 deletions src/Proxarr.Api/Configuration/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ public sealed class AppConfiguration
{
public const string SECTION_NAME = nameof(AppConfiguration);

public string LOG_FOLDER { get; set; }
public string? LOG_FOLDER { get; set; }

public string TAG_NAME { get; set; }
public string? TAG_NAME { get; set; }

public string TMDB_API_KEY { get; set; }
public required string TMDB_API_KEY { get; set; }

public string FULL_SCAN_CRON { get; set; } = "0 6 * * 1";

public List<ClientInstance> Clients { get; set; }

Check warning on line 15 in src/Proxarr.Api/Configuration/AppConfiguration.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Clients' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 15 in src/Proxarr.Api/Configuration/AppConfiguration.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Clients' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

private Dictionary<string, string[]> _watchProviders;

Check warning on line 17 in src/Proxarr.Api/Configuration/AppConfiguration.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_watchProviders' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

/// <summary>
/// Watch providers (ex: US:Netflix,US:Amazon Prime Video)
/// Transform Watch providers (ex: US:Netflix,US:Amazon Prime Video) to a dictionary
/// grouped by region
/// </summary>
public Dictionary<string, string[]> WatchProviders
{
Expand All @@ -32,9 +37,9 @@ private Dictionary<string, string[]> InitProviders()

var dict = new Dictionary<string, string[]>();

foreach (var region in WATCH_PROVIDERS!.Split(','))
foreach (var item in WATCH_PROVIDERS!.Split(','))
{
var parts = region.Split(':');
var parts = item.Split(':');
if (parts.Length != 2)
{
throw new FormatException("Malformed WATCH_PROVIDERS! Must follow this format: REGION:WatchProvider, REGION:WatchProvider, ... ex (US:Netflix,FR:Youtube)");
Expand All @@ -52,4 +57,17 @@ private Dictionary<string, string[]> InitProviders()
return dict;
}
}

public sealed class ClientInstance
{
/// <summary>
/// The name of the application (Sonarr or Radarr)
/// </summary>
public required string Application { get; set; }
public required string BaseUrl { get; set; }
public required string ApiKey { get; set; }

public bool IsSonarr => Application.Equals("Sonarr", StringComparison.OrdinalIgnoreCase);
public bool IsRadarr => Application.Equals("Radarr", StringComparison.OrdinalIgnoreCase);
}
}
135 changes: 135 additions & 0 deletions src/Proxarr.Api/Core/CronJobService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using Cronos;

namespace Proxarr.Api.Core
{
public abstract class CronJobService(string cronExpression, TimeZoneInfo timeZoneInfo, ILogger logger) : IHostedService, IDisposable
{
private System.Timers.Timer? _timer;
private readonly CronExpression _expression = CronExpression.Parse(cronExpression);
private Task? _executingTask;
private CancellationTokenSource _stoppingCts = new();
private readonly SemaphoreSlim _schedulerCycle = new(0);

public virtual Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("{JobName}: started with expression [{Expression}].", GetType().Name, cronExpression);
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

_executingTask = ScheduleJob(_stoppingCts.Token);
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
}

protected virtual async Task ScheduleJob(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var next = _expression.GetNextOccurrence(DateTimeOffset.Now, timeZoneInfo);
if (!next.HasValue) continue;

logger.LogInformation("{JobName}: scheduled next run at {NextRun}", GetType().Name, next.ToString());
var delay = next.Value - DateTimeOffset.Now;
if (delay.TotalMilliseconds <= 0) // prevent non-positive values from being passed into Timer
{
logger.LogInformation("{LoggerName}: scheduled next run is in the past. Moving to next.", GetType().Name);
continue;
}

_timer = new System.Timers.Timer(delay.TotalMilliseconds);
_timer.Elapsed += async (_, _) =>
{
try
{
_timer.Dispose(); // reset and dispose timer
_timer = null;

if (!cancellationToken.IsCancellationRequested)
{
await DoWork(cancellationToken);
}
}
catch (OperationCanceledException)
{
logger.LogInformation("{LoggerName}: job received cancellation signal, stopping...", GetType().Name);
}
catch (Exception e)
{
logger.LogError(e, "{LoggerName}: an error happened during execution of the job", GetType().Name);
}
finally
{
_schedulerCycle.Release(); // Let the outer loop know that the next occurrence can be calculated.
}
};
_timer.Start();
await _schedulerCycle.WaitAsync(cancellationToken); // Wait nicely for any timer result.
}
}
catch (OperationCanceledException)
{
logger.LogInformation("{LoggerName}: job received cancellation signal, stopping...", GetType().Name);
}
}

public virtual async Task DoWork(CancellationToken cancellationToken)
{
await Task.Delay(5000, cancellationToken); // do the work
}

public virtual async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("{JobName}: stopping...", GetType().Name);
_timer?.Stop();
_timer?.Dispose();
await _stoppingCts.CancelAsync();
logger.LogInformation("{JobName}: stopped.", GetType().Name);
}

public virtual void Dispose()
{
_timer?.Dispose();
_executingTask?.Dispose();
_schedulerCycle.Dispose();
_stoppingCts.Dispose();
GC.SuppressFinalize(this);
}
}

#pragma warning disable S2326 // Unused type parameters should be removed
public interface IScheduleConfig<T>
#pragma warning restore S2326 // Unused type parameters should be removed
{
string CronExpression { get; set; }
TimeZoneInfo TimeZoneInfo { get; set; }
}

public class ScheduleConfig<T> : IScheduleConfig<T>
{
public string CronExpression { get; set; } = string.Empty;
public TimeZoneInfo TimeZoneInfo { get; set; } = TimeZoneInfo.Local;
}

public static class ScheduledServiceExtensions
{
public static IServiceCollection AddCronJob<T>(this IServiceCollection services, Action<IScheduleConfig<T>> options) where T : CronJobService
{
if (options == null)
{
throw new ArgumentNullException(nameof(options), "Please provide Schedule Configurations.");
}

var config = new ScheduleConfig<T>();
options.Invoke(config);

if (string.IsNullOrWhiteSpace(config.CronExpression))
{
throw new ArgumentNullException(nameof(options), "Empty Cron Expression is not allowed.");
}

services.AddSingleton<IScheduleConfig<T>>(config);
services.AddHostedService<T>();
return services;
}
}
}
49 changes: 49 additions & 0 deletions src/Proxarr.Api/HostedServices/FullScanHostedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Proxarr.Api.Core;
using Proxarr.Api.Services;

namespace Proxarr.Api.HostedServices
{
public class FullScanHostedService : CronJobService
{
private IRadarrService _radarrService;
private ISonarrService _sonarrService;
private IServiceScope _scope;
private readonly ILogger<FullScanHostedService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;

public FullScanHostedService(IScheduleConfig<FullScanHostedService> config,

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_radarrService' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_sonarrService' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_scope' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_radarrService' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_sonarrService' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 14 in src/Proxarr.Api/HostedServices/FullScanHostedService.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_scope' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
ILogger<FullScanHostedService> logger,
IServiceScopeFactory serviceScopeFactory) : base(config.CronExpression, config.TimeZoneInfo, logger)
{
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
}

public override Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("{ServiceName} starts.", nameof(FullScanHostedService));
_scope = _serviceScopeFactory.CreateScope();
_radarrService = _scope.ServiceProvider.GetRequiredService<IRadarrService>();
_sonarrService = _scope.ServiceProvider.GetRequiredService<ISonarrService>();
return base.StartAsync(cancellationToken);
}

public override Task DoWork(CancellationToken cancellationToken)
{
_logger.LogInformation("CronJob {ServiceName} is working.", nameof(FullScanHostedService));
return Task.WhenAll(_radarrService.FullScan(cancellationToken), _sonarrService.FullScan(cancellationToken));
}

public override Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("CronJob {ServiceName} is stopping.", nameof(FullScanHostedService));
return base.StopAsync(cancellationToken);
}

public override void Dispose()
{
_scope?.Dispose();
base.Dispose();
}
}
}
2 changes: 1 addition & 1 deletion src/Proxarr.Api/Models/MediaAdded.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class MediaAdded
public required string EventType { get; set; }

[JsonPropertyName("instanceName")]
public required string InstanceName { get; set; }
public string? InstanceName { get; set; }

[JsonPropertyName("applicationUrl")]
public required string ApplicationUrl { get; set; }
Expand Down
4 changes: 2 additions & 2 deletions src/Proxarr.Api/Models/MovieAdded.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ public class Movie
public string? ReleaseDate { get; set; }

[JsonPropertyName("folderPath")]
public required string FolderPath { get; set; }
public string? FolderPath { get; set; }

[JsonPropertyName("tmdbId")]
public int TmdbId { get; set; }
public required int TmdbId { get; set; }

[JsonPropertyName("imdbId")]
public string? ImdbId { get; set; }
Expand Down
11 changes: 8 additions & 3 deletions src/Proxarr.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
using Proxarr.Api.Configuration;
using Proxarr.Api.Core;
using Proxarr.Api.HostedServices;
using Proxarr.Api.Services;
using Radarr.Http.Client;
using Scalar.AspNetCore;
using Serilog;
using Sonarr.Http.Client;
using System.Text.Json.Serialization;
using System.Text.Json;
using TMDbLib.Client;
using Proxarr.Api.Configuration;


var builder = WebApplication.CreateBuilder(args);
Expand Down Expand Up @@ -61,6 +60,12 @@
.AddHttpClient<SonarrClient>()
.AddHttpMessageHandler<ApiKeyDelegatingHandler>();

builder.Services.AddCronJob<FullScanHostedService>(c =>
{
c.TimeZoneInfo = TimeZoneInfo.Local;
c.CronExpression = Environment.GetEnvironmentVariable("FULL_SCAN_CRON") ?? builder.Configuration.GetValue<string>("AppConfiguration:FULL_SCAN_CRON")!;
});

var app = builder.Build();

// Configure the HTTP request pipeline.
Expand Down
1 change: 1 addition & 0 deletions src/Proxarr.Api/Proxarr.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Client" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
Expand Down
13 changes: 13 additions & 0 deletions src/Proxarr.Api/Services/IRadarrService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ namespace Proxarr.Api.Services
{
public interface IRadarrService
{
/// <summary>
/// Full scan of Radarr library for qualifying movies
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task FullScan(CancellationToken cancellationToken);

/// <summary>
/// Qualify movie
/// </summary>
/// <param name="movieAdded"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<string> Qualify(MovieAdded movieAdded, CancellationToken cancellationToken);
}
}
13 changes: 13 additions & 0 deletions src/Proxarr.Api/Services/ISonarrService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ namespace Proxarr.Api.Services
{
public interface ISonarrService
{
/// <summary>
/// Full scan of Sonarr library for qualifying tv shows
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task FullScan(CancellationToken cancellationToken);

/// <summary>
/// Qualify tv show
/// </summary>
/// <param name="tvAdded"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<string> Qualify(TvAdded tvAdded, CancellationToken cancellationToken);
}
}
Loading

0 comments on commit f7e7684

Please sign in to comment.