54 Commits
v0.6 ... v0.7.3

Author SHA1 Message Date
pcjones
c48db39d04 v0.7.3 (#74)
* Add newzbay categories; add title lookup API

* Update dependencies
2025-07-22 16:33:13 +02:00
pcjones
9b6fe09e45 Update README.md 2025-04-29 11:01:46 +02:00
pcjones
b3329f6899 Update README.md 2025-04-28 00:16:48 +02:00
pcjones
0801dbbc1d Update README.md 2025-04-28 00:16:10 +02:00
pcjones
305b83609b Update README.md 2025-04-28 00:01:53 +02:00
pcjones
59654b92fb Update README.md 2025-04-28 00:01:30 +02:00
pcjones
ad2aa34e53 Update README.md 2025-04-14 15:31:56 +02:00
pcjones
3ec0be1194 Update README.md 2025-04-14 15:31:29 +02:00
pcjones
b9f56a08ec Update README.md 2025-04-14 15:30:28 +02:00
pcjones
288f7a4de9 Update README.md (#69) 2025-04-14 15:30:04 +02:00
pcjones
344751c7f3 Update README.md (#60) 2025-01-22 19:45:22 +01:00
Jonas F
d15b9e2e90 Update run_on_seedbox.sh 2025-01-14 01:02:56 +01:00
Jonas F
30fad063b6 Update run_on_seedbox.sh 2025-01-14 01:00:56 +01:00
Jonas F
eeff05783e Create run_on_seedbox.sh 2025-01-14 01:00:26 +01:00
pcjones
37673f8a6c Fix wrong check for empty api key again -_- 2025-01-13 23:14:10 +01:00
pcjones
d2eaac7a6c Fix wrong check for empty API key 2025-01-13 23:09:24 +01:00
pcjones
aa3765bcf2 Fix Proxy not working if no api key was set 2025-01-13 23:01:14 +01:00
pcjones
e81a956cc4 Add missing curly bracket 2025-01-13 21:53:05 +01:00
pcjones
e7f838cd61 Merge branch 'develop' 2025-01-13 21:29:47 +01:00
Jonas F
3764991e63 Merge Develop in master (#57) (#58)
* Merge master in develop (#55)

* Fix reachable and IP leak test (#44)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

* Revert "Fix reachable and IP leak test (#44)" (#46)

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------



* Add IpLeakTest environment variable to docker compose

---------



* Create Dockerfile.arm64

---------



* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

* Fix too many Unauthorized access attempt warnings

---------

Co-authored-by: akuntsch <github@akuntsch.de>
2025-01-13 21:28:34 +01:00
Jonas F
d2a3963006 Merge Develop in master (#57)
* Merge master in develop (#55)

* Fix reachable and IP leak test (#44)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

* Revert "Fix reachable and IP leak test (#44)" (#46)

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------

Co-authored-by: Jonas F <github@pcjones.de>

* Add IpLeakTest environment variable to docker compose

---------

Co-authored-by: akuntsch <github@akuntsch.de>

* Create Dockerfile.arm64

---------

Co-authored-by: akuntsch <github@akuntsch.de>

* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

* Fix too many Unauthorized access attempt warnings

---------

Co-authored-by: akuntsch <github@akuntsch.de>
2025-01-13 21:28:01 +01:00
Jonas F
270458a2a3 Merge branch 'master' into develop 2025-01-13 21:27:51 +01:00
pcjones
e3d4222f16 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2025-01-13 21:26:28 +01:00
pcjones
ed044e9a59 Fix too many Unauthorized access attempt warnings 2025-01-13 21:26:24 +01:00
Jonas F
9cdf1950c6 Mergter (#56)
* Merge master in develop (#55)

* Fix reachable and IP leak test (#44)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

* Revert "Fix reachable and IP leak test (#44)" (#46)

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------

Co-authored-by: Jonas F <github@pcjones.de>

* Add IpLeakTest environment variable to docker compose

---------

Co-authored-by: akuntsch <github@akuntsch.de>

* Create Dockerfile.arm64

---------

Co-authored-by: akuntsch <github@akuntsch.de>

* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

---------

Co-authored-by: akuntsch <github@akuntsch.de>
2025-01-13 21:20:03 +01:00
Jonas F
5463794a4f Merge branch 'master' into develop 2025-01-13 21:19:45 +01:00
pcjones
dd6b4c9d3b Add default settings to appsettings 2025-01-13 21:16:16 +01:00
pcjones
02a6ec2548 Add API Key auth 2025-01-13 21:14:31 +01:00
pcjones
275f29ec11 Make proxy port configurable 2025-01-13 19:35:30 +01:00
pcjones
f916aa3761 Make proxy port configurable 2025-01-13 19:35:20 +01:00
pcjones
b6390c15a1 Add configurable cache duration 2025-01-13 19:00:42 +01:00
Jonas F
e117826c6a Merge master in develop (#55)
* Fix reachable and IP leak test (#44)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

* Revert "Fix reachable and IP leak test (#44)" (#46)

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------

Co-authored-by: Jonas F <github@pcjones.de>

* Add IpLeakTest environment variable to docker compose

---------

Co-authored-by: akuntsch <github@akuntsch.de>

* Create Dockerfile.arm64

---------

Co-authored-by: akuntsch <github@akuntsch.de>
2025-01-13 18:49:26 +01:00
Jonas F
83905622cb Merge branch 'develop' into master 2025-01-13 18:48:49 +01:00
Jonas F
9207d6ec7c Create Dockerfile.arm64 2025-01-02 15:44:07 +01:00
pcjones
17456c6f90 Clarify error message 2024-11-08 13:56:29 +01:00
pcjones
c581233dbf Bypass domain check for localhost 2024-11-02 15:51:05 +01:00
pcjones
6fc399131b Allow port in URL 2024-11-02 15:46:52 +01:00
pcjones
31ac409d41 Fix logger 2024-10-25 13:37:50 +02:00
pcjones
03b50a24fd Log content on error at ProcessContent 2024-10-25 13:26:43 +02:00
PCJones
7ed68f2b84 Fix typo 2024-10-22 10:33:37 +02:00
pcjones
65847f34bc Fix missing semicolon 2024-10-21 23:34:12 +02:00
pcjones
29da771484 Add more logging to reachable check 2024-10-21 23:32:18 +02:00
Jonas F
cf3a5ab68a Release 0.6.1 (#48)
* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------

Co-authored-by: Jonas F <github@pcjones.de>

* Add IpLeakTest environment variable to docker compose

---------

Co-authored-by: akuntsch <github@akuntsch.de>
2024-10-21 17:28:31 +02:00
pcjones
b8a1c64039 Add IpLeakTest environment variable to docker compose 2024-10-21 17:20:28 +02:00
pcjones
4ffdf9f53a Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-10-21 17:16:17 +02:00
akuntsch
4c582c7a6c Fix reachable and ipleak test (#47)
* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test

---------

Co-authored-by: Jonas F <github@pcjones.de>
2024-10-21 17:15:11 +02:00
Jonas F
46e1baf53c Revert "Fix reachable and IP leak test (#44)" (#46)
This reverts commit 3f5d7bbef3.
2024-10-21 14:26:55 +02:00
akuntsch
3f5d7bbef3 Fix reachable and IP leak test (#44)
* Fix reachable check

Fixes failing reachable checks when Basic Authentication is enabled in
Sonarr, Radarr, etc.

* Add option to disable IP leak test
2024-10-21 14:26:35 +02:00
pcjones
e95d18ed91 Clarify error message 2024-10-21 14:25:03 +02:00
pcjones
95f5054829 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-10-12 14:24:43 +02:00
pcjones
2085a28da2 Fix typo 2024-09-30 14:10:56 +02:00
pcjones
0e38d5a0f3 Fix typos 2024-09-30 14:09:46 +02:00
pcjones
ee329c23e5 Fix typos 2024-09-30 14:09:32 +02:00
pcjones
c9ea74267b Fix typo 2024-09-30 14:09:01 +02:00
22 changed files with 465 additions and 125 deletions

11
Dockerfile.arm64 Normal file
View File

@@ -0,0 +1,11 @@
FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out
FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "UmlautAdaptarr.dll"]

View File

@@ -42,9 +42,10 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda
## Installation
Momentan ist Docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. Eine Unraid-App gibt es auch, einfach nach `umlautadaptarr` suchen.
[Link zum Docker Image](https://hub.docker.com/r/pcjones/umlautadaptarr)
- [Docker](https://hub.docker.com/r/pcjones/umlautadaptarr)
- Unraid: nach `umlautadaptarr` suchen
- [Proxmox LXC (unofficial)](https://community-scripts.github.io/ProxmoxVE/scripts?id=umlautadaptarr) - appsettings.json muss nach Installation konfiguriert werden
- [Seedbox/Binary](https://github.com/PCJones/UmlautAdaptarr/blob/master/run_on_seedbox.sh)
Nicht benötigte Umgebungsvariablen, z.B. falls Readarr oder Lidarr nicht genutzt werden, können entfernt werden.
@@ -120,11 +121,13 @@ Sonarr erwartet immer den Englischen Namen, der hier natürlich nicht gegeben is
## Kontakt & Support
- Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst.
- [Telegram](https://t.me/pc_jones)
- Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM)
- [UsenetDE Discord Server](https://discord.gg/src6zcH4rr) -> #umlautadaptarr
## Spenden
Über eine Spende freue ich mich natürlich immer :D
PayPal: https://paypal.me/pcjones1
<a href="https://www.buymeacoffee.com/pcjones" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="60px" width="217px" ></a>
<a href="https://coindrop.to/pcjones" target="_blank"><img src="https://coindrop.to/embed-button.png" style="border-radius: 10px; height: 57px !important;width: 229px !important;" alt="Coindrop.to me"></img></a>
Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!

View File

@@ -1,19 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text;
using System.Xml.Linq;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Controllers
{
public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase
public class CapsController(ProxyRequestService proxyRequestService, IOptions<GlobalOptions> options, ILogger<CapsController> logger) : ControllerBase
{
private readonly ProxyRequestService _proxyRequestService = proxyRequestService;
private readonly GlobalOptions _options = options.Value;
private readonly ILogger<CapsController> _logger = logger;
[HttpGet]
public async Task<IActionResult> Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey)
public async Task<IActionResult> Caps([FromRoute] string apiKey, [FromRoute] string domain, [FromQuery] string? apikey)
{
if (!UrlUtilities.IsValidDomain(domain))
if (!string.IsNullOrEmpty(apikey) && !apiKey.Equals(apiKey))
{
_logger.LogWarning("Invalid or missing API key for request.");
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
if (!domain.StartsWith("localhost") && !UrlUtilities.IsValidDomain(domain))
{
return NotFound($"{domain} is not a valid URL.");
}

View File

@@ -1,23 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Controllers
{
public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService) : ControllerBase
public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, IOptions<GlobalOptions> options, ILogger<SearchControllerBase> logger) : ControllerBase
{
// TODO evaluate if this should be set to true by default
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true;
private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options,
protected async Task<IActionResult> BaseSearch(string apiKey,
string domain,
IDictionary<string, string> queryParameters,
SearchItem? searchItem = null)
{
try
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
if (!UrlUtilities.IsValidDomain(domain))
{
return NotFound($"{domain} is not a valid URL.");
@@ -110,7 +117,15 @@ namespace UmlautAdaptarr.Controllers
private string ProcessContent(string content, SearchItem? searchItem)
{
return titleMatchingService.RenameTitlesInContent(content, searchItem);
try
{
return titleMatchingService.RenameTitlesInContent(content, searchItem);
}
catch (Exception ex)
{
logger.LogError($"Error at ProcessContent: {ex.Message}{Environment.NewLine}Content:{Environment.NewLine}{content}");
}
return null;
}
public async Task<AggregatedSearchResult> AggregateSearchResults(
@@ -150,29 +165,50 @@ namespace UmlautAdaptarr.Controllers
return aggregatedResult;
}
internal bool AssureApiKey(string apiKey)
{
if (!string.IsNullOrEmpty(options.Value.ApiKey) && !apiKey.Equals(options.Value.ApiKey))
{
logger.LogWarning("Invalid or missing API key for request.");
return false;
}
return true;
}
}
public class SearchController(ProxyRequestService proxyRequestService,
TitleMatchingService titleMatchingService,
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyRequestService, titleMatchingService)
SearchItemLookupService searchItemLookupService,
IOptions<GlobalOptions> options,
ILogger<SearchControllerBase> logger) : SearchControllerBase(proxyRequestService, titleMatchingService, options, logger)
{
public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"];
public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"];
[HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> MovieSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
return await BaseSearch(options, domain, queryParameters);
return await BaseSearch(apiKey, domain, queryParameters);
}
[HttpGet]
public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> GenericSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
@@ -198,21 +234,31 @@ namespace UmlautAdaptarr.Controllers
}
}
return await BaseSearch(options, domain, queryParameters, searchItem);
return await BaseSearch(apiKey, domain, queryParameters, searchItem);
}
[HttpGet]
public async Task<IActionResult> BookSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> BookSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
return await BaseSearch(options, domain, queryParameters);
return await BaseSearch(apiKey, domain, queryParameters);
}
[HttpGet]
public async Task<IActionResult> TVSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> TVSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
@@ -229,16 +275,21 @@ namespace UmlautAdaptarr.Controllers
searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title);
}
return await BaseSearch(options, domain, queryParameters, searchItem);
return await BaseSearch(apiKey, domain, queryParameters, searchItem);
}
[HttpGet]
public async Task<IActionResult> MusicSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> MusicSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
return await BaseSearch(options, domain, queryParameters);
return await BaseSearch(apiKey, domain, queryParameters);
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Services;
namespace UmlautAdaptarr.Controllers
{
[ApiController]
[Route("titlelookup/")]
public class TitleLookupController(CacheService cacheService, IOptions<GlobalOptions> options) : ControllerBase
{
GlobalOptions _options = options.Value;
[HttpGet]
public IActionResult GetOriginalTitle([FromQuery] string changedTitle)
{
if (!_options.EnableChangedTitleCache)
{
return StatusCode(501, "Set SETTINGS__EnableChangedTitleCache to true to use this endpoint.");
}
if (string.IsNullOrWhiteSpace(changedTitle))
return BadRequest("changedTitle is required.");
var originalTitle = cacheService.GetOriginalTitleFromRenamed(changedTitle);
return originalTitle != null
? Ok(new { changedTitle, originalTitle })
: NotFound($"Original title not found for '{changedTitle}'.");
}
}
}

View File

@@ -14,5 +14,25 @@
/// The User-Agent string used in HTTP requests.
/// </summary>
public string UserAgent { get; set; }
}
/// <summary>
/// The duration in minutes to cache the indexer requests.
/// </summary>
public int IndexerRequestsCacheDurationInMinutes { get; set; } = 12;
/// <summary>
/// API key for requests to the UmlautAdaptarr. Optional.
/// </summary>
public string? ApiKey { get; set; } = null;
/// <summary>
/// Proxy port for the internal UmlautAdaptarr proxy.
/// </summary>
public int ProxyPort { get; set; } = 5006;
/// <summary>
/// Enable or disable the cache for changed titles.
/// </summary>
public bool EnableChangedTitleCache { get; set; } = false;
}
}

View File

@@ -10,8 +10,11 @@ internal class Program
{
private static void Main(string[] args)
{
Helper.ShowLogo();
Helper.ShowInformation();
MainAsync(args).Wait();
}
private static async Task MainAsync(string[] args)
{
// TODO:
// add option to sort by nzb age
var builder = WebApplication.CreateBuilder(args);
@@ -43,9 +46,9 @@ internal class Program
builder.AddTitleLookupService();
builder.Services.AddSingleton<SearchItemLookupService>();
builder.Services.AddSingleton<TitleMatchingService>();
builder.AddSonarrSupport();
builder.AddLidarrSupport();
builder.AddReadarrSupport();
await builder.AddSonarrSupport();
await builder.AddLidarrSupport();
await builder.AddReadarrSupport();
builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyRequestService>();
builder.Services.AddSingleton<ArrApplicationFactory>();
@@ -54,37 +57,44 @@ internal class Program
var app = builder.Build();
Helper.ShowLogo();
if (app.Configuration.GetValue<bool>("IpLeakTest:Enabled"))
{
await Helper.ShowInformation();
}
GlobalStaticLogger.Initialize(app.Services.GetService<ILoggerFactory>()!);
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllerRoute("caps",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Caps", action = "Caps" },
new { t = new TRouteConstraint("caps") });
app.MapControllerRoute("movie-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MovieSearch" },
new { t = new TRouteConstraint("movie") });
app.MapControllerRoute("tv-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "TVSearch" },
new { t = new TRouteConstraint("tvsearch") });
app.MapControllerRoute("music-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MusicSearch" },
new { t = new TRouteConstraint("music") });
app.MapControllerRoute("book-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "BookSearch" },
new { t = new TRouteConstraint("book") });
app.MapControllerRoute("generic-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "GenericSearch" },
new { t = new TRouteConstraint("search") });
app.Run();

View File

@@ -48,6 +48,21 @@ public class SonarrClient : ArrClientBase
if (shows != null)
{
_logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName}).");
// Bulk request (germanTitle, aliases) for all shows
var tvdbIds = new List<string>();
foreach (var show in shows)
{
if ((string)show.tvdbId is not string tvdbId)
{
continue;
}
tvdbIds.Add(tvdbId);
}
var bulkTitleData = await _titleService.FetchGermanTitlesAndAliasesByExternalIdBulkAsync(tvdbIds);
string? germanTitle;
string[]? aliases;
foreach (var show in shows)
{
var tvdbId = (string)show.tvdbId;
@@ -57,8 +72,16 @@ public class SonarrClient : ArrClientBase
continue;
}
var (germanTitle, aliases) =
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
if (bulkTitleData.TryGetValue(tvdbId, out var titleData))
{
(germanTitle, aliases) = titleData;
}
else
{
(germanTitle, aliases) =
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
}
var searchItem = new SearchItem
(
(int)show.id,

View File

@@ -1,20 +1,19 @@
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Caching.Memory;
using System.Reflection.Metadata.Ecma335;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services
{
public partial class CacheService(IMemoryCache cache)
public partial class CacheService(IMemoryCache cache)
{
private readonly Dictionary<string, HashSet<string>> VariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> BookVariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
private const string TitleRenamePrefix = "title_rename_";
private static readonly TimeSpan TitleRenameCacheDuration = TimeSpan.FromHours(12);
public void CacheSearchItem(SearchItem item)
public void CacheSearchItem(SearchItem item)
{
var prefix = item.MediaType;
var cacheKey = $"{prefix}_extid_{item.ExternalId}";
@@ -196,8 +195,19 @@ namespace UmlautAdaptarr.Services
return null;
}
public void CacheTitleRename(string changedTitle, string originalTitle)
{
if (string.IsNullOrWhiteSpace(changedTitle) || string.IsNullOrWhiteSpace(originalTitle))
return;
[GeneratedRegex("\\s")]
private static partial Regex WhiteSpaceRegex();
}
var key = $"{TitleRenamePrefix}{changedTitle.Trim().ToLowerInvariant()}";
cache.Set(key, originalTitle, TitleRenameCacheDuration);
}
public string? GetOriginalTitleFromRenamed(string changedTitle)
{
var key = $"{TitleRenamePrefix}{changedTitle.Trim().ToLowerInvariant()}";
return cache.TryGetValue(key, out string? originalTitle) ? originalTitle : null;
}
}
}

View File

@@ -4,7 +4,7 @@ using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services.Factory
{
/// <summary>
/// Factory for creating RrApplication instances.
/// Factory for creating ArrApplication instances.
/// </summary>
public class ArrApplicationFactory
{
@@ -33,26 +33,26 @@ namespace UmlautAdaptarr.Services.Factory
/// <summary>
/// Constructor for the ArrApplicationFactory.
/// </summary>
/// <param name="rrArrApplications">A dictionary of IArrApplication instances.</param>
/// <param name="arrApplications">A dictionary of IArrApplication instances.</param>
/// <param name="logger">Logger Instanz</param>
public ArrApplicationFactory(IDictionary<string, IArrApplication> rrArrApplications, ILogger<ArrApplicationFactory> logger)
public ArrApplicationFactory(IDictionary<string, IArrApplication> arrApplications, ILogger<ArrApplicationFactory> logger)
{
_logger = logger;
try
{
SonarrInstances = rrArrApplications.Values.OfType<SonarrClient>();
LidarrInstances = rrArrApplications.Values.OfType<LidarrClient>();
ReadarrInstances = rrArrApplications.Values.OfType<ReadarrClient>();
AllInstances = rrArrApplications;
SonarrInstances = arrApplications.Values.OfType<SonarrClient>();
LidarrInstances = arrApplications.Values.OfType<LidarrClient>();
ReadarrInstances = arrApplications.Values.OfType<ReadarrClient>();
AllInstances = arrApplications;
if (!AllInstances.Values.Any())
if (AllInstances.Values.Count == 0)
{
throw new Exception("No RrApplication could be successfully initialized. This could be due to a faulty configuration");
throw new Exception("No ArrApplication could be successfully initialized. This could be due to a faulty configuration");
}
}
catch (Exception e)
{
_logger.LogError("Error while Register ArrFactory. This might be a Config Problem", e.Message);
_logger.LogError("Error while registering ArrFactory. This is most likely a config problem, please check your environment variables.", e.Message);
throw;
}
}

View File

@@ -1,6 +1,8 @@
using System.Net;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UmlautAdaptarr.Options;
namespace UmlautAdaptarr.Services
{
@@ -8,15 +10,16 @@ namespace UmlautAdaptarr.Services
{
private TcpListener _listener;
private readonly ILogger<HttpProxyService> _logger;
private readonly int _proxyPort = 5006; // TODO move to appsettings.json
private readonly IHttpClientFactory _clientFactory;
private readonly GlobalOptions _options;
private readonly HashSet<string> _knownHosts = [];
private readonly object _hostsLock = new();
private readonly IConfiguration _configuration;
private static readonly string[] newLineSeparator = ["\r\n"];
public HttpProxyService(ILogger<HttpProxyService> logger, IHttpClientFactory clientFactory, IConfiguration configuration)
public HttpProxyService(ILogger<HttpProxyService> logger, IHttpClientFactory clientFactory, IConfiguration configuration, IOptions<GlobalOptions> options)
{
_options = options.Value;
_logger = logger;
_configuration = configuration;
_clientFactory = clientFactory;
@@ -39,6 +42,24 @@ namespace UmlautAdaptarr.Services
var bytesRead = await clientStream.ReadAsync(buffer);
var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead);
if (!string.IsNullOrEmpty(_options.ApiKey))
{
var headers = ParseHeaders(buffer, bytesRead);
if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) ||
!ValidateApiKey(proxyAuthorizationHeader))
{
var isFirstRequest = !headers.ContainsKey("Proxy-Authorization");
if (!isFirstRequest)
{
_logger.LogWarning("Unauthorized access attempt.");
}
await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n"));
clientSocket.Close();
return;
}
}
if (requestString.StartsWith("CONNECT"))
{
// Handle HTTPS CONNECT request
@@ -51,6 +72,19 @@ namespace UmlautAdaptarr.Services
}
}
private bool ValidateApiKey(string proxyAuthorizationHeader)
{
// Expect the header to be in the format: "Basic <base64encodedApiKey>"
if (proxyAuthorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
var encodedKey = proxyAuthorizationHeader["Basic ".Length..].Trim();
var decodedKey = Encoding.ASCII.GetString(Convert.FromBase64String(encodedKey));
var password = decodedKey.Split(':')[^1];
return password == _options.ApiKey;
}
return false;
}
private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket)
{
var (host, port) = ParseTargetInfo(requestString);
@@ -96,7 +130,9 @@ namespace UmlautAdaptarr.Services
var url = _configuration["Kestrel:Endpoints:Http:Url"];
var port = new Uri(url).Port;
var modifiedUri = $"http://localhost:{port}/_/{uri.Host}{uri.PathAndQuery}";
var apiKey = string.IsNullOrEmpty(_options.ApiKey) ? "_" : _options.ApiKey;
var modifiedUri = $"http://localhost:{port}/{apiKey}/{uri.Host}{uri.PathAndQuery}";
using var client = _clientFactory.CreateClient();
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri);
httpRequestMessage.Headers.Add("User-Agent", userAgent);
@@ -168,7 +204,7 @@ namespace UmlautAdaptarr.Services
public Task StartAsync(CancellationToken cancellationToken)
{
_listener = new TcpListener(IPAddress.Any, _proxyPort);
_listener = new TcpListener(IPAddress.Any, _options.ProxyPort);
_listener.Start();
Task.Run(() => HandleRequests(cancellationToken), cancellationToken);
return Task.CompletedTask;

View File

@@ -81,7 +81,7 @@ namespace UmlautAdaptarr.Services
if (responseMessage.IsSuccessStatusCode)
{
_cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(12));
_cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(_options.IndexerRequestsCacheDurationInMinutes));
}
return responseMessage;

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Utilities;
@@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Services
lastRequestTime = DateTime.Now;
}
// TODO add cache, TODO add bulk request
// TODO add caching
public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId)
{
try
@@ -68,6 +69,68 @@ namespace UmlautAdaptarr.Services
return (null, null);
}
public async Task<Dictionary<string, (string? germanTitle, string[]? aliases)>> FetchGermanTitlesAndAliasesByExternalIdBulkAsync(IEnumerable<string> tvdbIds)
{
try
{
await EnsureMinimumDelayAsync();
var httpClient = clientFactory.CreateClient();
var bulkApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?bulk=true";
logger.LogInformation($"TitleApiService POST {UrlUtilities.RedactApiKey(bulkApiUrl)}");
// Prepare POST request payload
var payload = new { tvdbIds = tvdbIds.ToArray() };
var jsonPayload = JsonConvert.SerializeObject(payload);
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
// Send POST request
var response = await httpClient.PostAsync(bulkApiUrl, content);
if (!response.IsSuccessStatusCode)
{
logger.LogError($"Failed to fetch German titles via bulk API. Status Code: {response.StatusCode}");
return [];
}
var responseContent = await response.Content.ReadAsStringAsync();
var bulkApiResponseData = JsonConvert.DeserializeObject<dynamic>(responseContent);
if (bulkApiResponseData == null || bulkApiResponseData.status != "success")
{
logger.LogError($"Parsing UmlautAdaptarr Bulk API response resulted in null or an error status.");
return [];
}
// Process response data
var results = new Dictionary<string, (string? germanTitle, string[]? aliases)>();
foreach (var entry in bulkApiResponseData.data)
{
string tvdbId = entry.tvdbId;
string? germanTitle = entry.germanTitle;
string[]? aliases = null;
if (entry.aliases != null)
{
JArray aliasesArray = JArray.FromObject(entry.aliases);
aliases = aliasesArray.Children<JObject>()
.Select(alias => alias["name"].ToString())
.ToArray();
}
results[tvdbId] = (germanTitle, aliases);
}
logger.LogInformation($"Successfully fetched German titles for {results.Count} TVDB IDs via bulk API.");
return results;
}
catch (Exception ex)
{
logger.LogError($"Error fetching German titles in bulk: {ex.Message}");
return new Dictionary<string, (string? germanTitle, string[]? aliases)>();
}
}
public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title)
{
try

View File

@@ -1,14 +1,18 @@
using Microsoft.Extensions.FileSystemGlobbing.Internal;
using Microsoft.Extensions.Options;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services
{
public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger)
public partial class TitleMatchingService(CacheService cacheService, ILogger<TitleMatchingService> logger, IOptions<GlobalOptions> options)
{
public string RenameTitlesInContent(string content, SearchItem? searchItem)
public GlobalOptions _options { get; } = options.Value;
public string RenameTitlesInContent(string content, SearchItem? searchItem)
{
var xDoc = XDocument.Parse(content);
@@ -46,10 +50,10 @@ namespace UmlautAdaptarr.Services
switch (mediaType)
{
case "tv":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
FindAndReplaceForMoviesAndTV(searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break;
case "movie":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
FindAndReplaceForMoviesAndTV(searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break;
case "audio":
FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!);
@@ -94,7 +98,11 @@ namespace UmlautAdaptarr.Services
// Update the title element
titleElement.Value = updatedTitle;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{updatedTitle}'");
if (_options.EnableChangedTitleCache)
{
cacheService.CacheTitleRename(updatedTitle, originalTitle);
}
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{updatedTitle}'");
}
else
{
@@ -161,7 +169,7 @@ namespace UmlautAdaptarr.Services
}
// This method replaces the first variation that starts at the beginning of the release title
private static void FindAndReplaceForMoviesAndTV(ILogger<TitleMatchingService> logger, SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
private void FindAndReplaceForMoviesAndTV(SearchItem searchItem, XElement? titleElement, string originalTitle, string normalizedOriginalTitle)
{
var titleMatchVariations = searchItem.TitleMatchVariations;
var expectedTitle = searchItem.ExpectedTitle;
@@ -218,8 +226,11 @@ namespace UmlautAdaptarr.Services
// Update the title element's value with the new title
//titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})";
titleElement.Value = newTitle;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'");
if (_options.EnableChangedTitleCache)
{
cacheService.CacheTitleRename(newTitle, originalTitle);
}
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'");
break;
}
}
@@ -298,23 +309,23 @@ namespace UmlautAdaptarr.Services
return null;
}
if (category == "7000" || category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase))
if (category == "7000" || category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase) ||category.StartsWith("Bücher", StringComparison.OrdinalIgnoreCase))
{
return "book";
}
else if (category == "2000" || category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase))
else if (category == "2000" || category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Filme", StringComparison.OrdinalIgnoreCase))
{
return "movies";
}
else if (category == "5000" || category.StartsWith("TV", StringComparison.OrdinalIgnoreCase))
else if (category == "5000" || category.StartsWith("TV", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Serien", StringComparison.OrdinalIgnoreCase))
{
return "tv";
}
else if (category == "3030" || category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase))
else if (category == "3030" || category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase) || category.Contains("Hörbuch", StringComparison.OrdinalIgnoreCase))
{
return "book";
}
else if (category == "3000" || category.StartsWith("Audio", StringComparison.OrdinalIgnoreCase))
else if (category == "3000" || category.StartsWith("Audio", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Musik", StringComparison.OrdinalIgnoreCase))
{
return "audio";
}

View File

@@ -9,13 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="IL.FluentValidation.Extensions.Options" Version="11.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
</Project>

View File

@@ -11,10 +11,10 @@ public static class Helper
"\r\n _ _ _ _ ___ _ _ \r\n| | | | | | | | / _ \\ | | | | \r\n| | | |_ __ ___ | | __ _ _ _| |_/ /_\\ \\ __| | __ _ _ __ | |_ __ _ _ __ _ __ \r\n| | | | '_ ` _ \\| |/ _` | | | | __| _ |/ _` |/ _` | '_ \\| __/ _` | '__| '__|\r\n| |_| | | | | | | | (_| | |_| | |_| | | | (_| | (_| | |_) | || (_| | | | | \r\n \\___/|_| |_| |_|_|\\__,_|\\__,_|\\__\\_| |_/\\__,_|\\__,_| .__/ \\__\\__,_|_| |_| \r\n | | \r\n |_| \r\n");
}
public static void ShowInformation()
public static async Task ShowInformation()
{
Console.WriteLine("--------------------------[IP Leak Test]-----------------------------");
var ipInfo = GetPublicIpAddressInfoAsync().GetAwaiter().GetResult();
var ipInfo = await GetPublicIpAddressInfoAsync();
if (ipInfo != null)
{

View File

@@ -29,7 +29,7 @@ public static class ServicesExtensions
/// <param name="builder">The <see cref="WebApplicationBuilder" /> to configure the service collection.</param>
/// <param name="sectionName">The name of the configuration section containing service options.</param>
/// <returns>The configured <see cref="WebApplicationBuilder" />.</returns>
private static WebApplicationBuilder AddServicesWithOptions<TOptions, TService, TInterface>(
private static async Task<WebApplicationBuilder> AddServicesWithOptions<TOptions, TService, TInterface>(
this WebApplicationBuilder builder, string sectionName)
where TOptions : class, new()
where TService : class, TInterface
@@ -57,9 +57,9 @@ public static class ServicesExtensions
foreach (var option in optionsArray)
{
GlobalInstanceOptionsValidator validator = new GlobalInstanceOptionsValidator();
GlobalInstanceOptionsValidator validator = new();
var results = validator.Validate(option as GlobalInstanceOptions);
var results = await validator.ValidateAsync(option as GlobalInstanceOptions);
if (!results.IsValid)
{
@@ -68,7 +68,7 @@ public static class ServicesExtensions
Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}"));
}
throw new Exception("Please fix first you config and then Start UmlautAdaptarr again");
throw new Exception("Please fix cour environment variables and then Start UmlautAdaptarr again");
}
var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false);
@@ -143,7 +143,7 @@ public static class ServicesExtensions
/// </summary>
/// <param name="builder">The <see cref="WebApplicationBuilder" /> to configure the service collection.</param>
/// <returns>The configured <see cref="WebApplicationBuilder" />.</returns>
public static WebApplicationBuilder AddSonarrSupport(this WebApplicationBuilder builder)
public static Task<WebApplicationBuilder> AddSonarrSupport(this WebApplicationBuilder builder)
{
// builder.Serviceses.AddSingleton<IOptionsMonitoSonarrInstanceOptionsns>, OptionsMonitoSonarrInstanceOptionsns>>();
return builder.AddServicesWithOptions<SonarrInstanceOptions, SonarrClient, IArrApplication>("Sonarr");
@@ -154,7 +154,7 @@ public static class ServicesExtensions
/// </summary>
/// <param name="builder">The <see cref="WebApplicationBuilder" /> to configure the service collection.</param>
/// <returns>The configured <see cref="WebApplicationBuilder" />.</returns>
public static WebApplicationBuilder AddLidarrSupport(this WebApplicationBuilder builder)
public static Task<WebApplicationBuilder> AddLidarrSupport(this WebApplicationBuilder builder)
{
return builder.AddServicesWithOptions<LidarrInstanceOptions, LidarrClient, IArrApplication>("Lidarr");
}
@@ -164,7 +164,7 @@ public static class ServicesExtensions
/// </summary>
/// <param name="builder">The <see cref="WebApplicationBuilder" /> to configure the service collection.</param>
/// <returns>The configured <see cref="WebApplicationBuilder" />.</returns>
public static WebApplicationBuilder AddReadarrSupport(this WebApplicationBuilder builder)
public static Task<WebApplicationBuilder> AddReadarrSupport(this WebApplicationBuilder builder)
{
return builder.AddServicesWithOptions<ReadarrInstanceOptions, ReadarrClient, IArrApplication>("Readarr");
}

View File

@@ -5,7 +5,8 @@ namespace UmlautAdaptarr.Utilities
{
public partial class UrlUtilities
{
[GeneratedRegex(@"^(?!http:\/\/)([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+.*)$")]
[GeneratedRegex(@"^(?!http:\/\/)([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?)$")]
private static partial Regex UrlMatchingRegex();
public static bool IsValidDomain(string domain)
{

View File

@@ -1,11 +1,15 @@
using System.Net;
using FluentValidation;
using FluentValidation;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
namespace UmlautAdaptarr.Validator;
public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOptions>
{
private readonly static HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(3)
};
public GlobalInstanceOptionsValidator()
{
RuleFor(x => x.Enabled).NotNull();
@@ -14,12 +18,14 @@ public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOp
{
RuleFor(x => x.Host)
.NotEmpty().WithMessage("Host is required when Enabled is true.")
.Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.")
.Must(BeReachable)
.WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings");
.Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.");
RuleFor(x => x.ApiKey)
.NotEmpty().WithMessage("ApiKey is required when Enabled is true.");
RuleFor(x => x)
.MustAsync(BeReachable)
.WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings");
});
}
@@ -29,54 +35,37 @@ public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOp
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
private static bool BeReachable(string url)
private static async Task<bool> BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken)
{
var endTime = DateTime.Now.AddMinutes(3);
var reachable = false;
var url = $"{opts.Host}/api?apikey={opts.ApiKey}";
while (DateTime.Now < endTime)
{
try
{
// TODO use HttpClient here
var request = (HttpWebRequest)WebRequest.Create(url);
request.AllowAutoRedirect = false;
request.Timeout = 3000;
using var response = (HttpWebResponse)request.GetResponse();
reachable = response.StatusCode == HttpStatusCode.OK;
if (reachable)
using var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
reachable = true;
break;
}
// If status is 301/302 (Found), follow the redirect manually
else if (response.StatusCode == HttpStatusCode.MovedPermanently || response.StatusCode == HttpStatusCode.Found)
else
{
var redirectUrl = response.Headers["Location"]; // Get the redirect URL
if (!string.IsNullOrEmpty(redirectUrl))
{
// Create a new request for the redirected URL
var redirectRequest = (HttpWebRequest)WebRequest.Create(redirectUrl);
redirectRequest.Timeout = 3000;
using var redirectResponse = (HttpWebResponse)redirectRequest.GetResponse();
reachable = redirectResponse.StatusCode == HttpStatusCode.OK;
if (reachable)
{
break;
}
}
Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}.");
}
}
catch
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
// Wait for 15 seconds for next try
Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds...");
Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds...");
Thread.Sleep(15000);
}
return reachable;
}
}

View File

@@ -20,7 +20,11 @@
// Settings__UmlautAdaptarrApiHost=https://umlautadaptarr.pcjones.de/api/v1
"Settings": {
"UserAgent": "UmlautAdaptarr/1.0",
"UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1"
"UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1",
"IndexerRequestsCacheDurationInMinutes": 12,
"ApiKey": null,
"ProxyPort": 5006,
"EnableChangedTitleCache": false // Set to true if you are using crowdnfo.net post processing script
},
"Sonarr": [
{
@@ -64,5 +68,10 @@
"Enabled": false,
"Host": "your_readarr_host_url",
"ApiKey": "your_readarr_api_key"
},
"IpLeakTest": {
// Docker Environment Variables:
// - IpLeakTest__Enabled: false (set to true to enable)
"Enabled": false
}
}

View File

@@ -32,3 +32,11 @@ services:
#- SONARR__1__ENABLED=false
#- SONARR__1__HOST=http://localhost:8989
#- SONARR__1__APIKEY=APIKEY
### Advanced options (with default values))
#- IpLeakTest__Enabled=false
#- SETTINGS__IndexerRequestsCacheDurationInMinutes=12 # How long to cache indexer requests for. Default is 12 minutes.
#- SETTINGS__ApiKey= # API key for requests to the UmlautAdaptarr. Optional, probably only needed for seedboxes.
#- SETTINGS__ProxyPort=5006 # Proxy port for the internal UmlautAdaptarr proxy used for Prowlarr.
#- Kestrel__Endpoints__Http__Url=http://[::]:5005 # HTTP port for the UmlautAdaptarr

52
run_on_seedbox.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Download linux binary from https://github.com/PCJones/UmlautAdaptarr/releases
# script by schumi4 - THX!
#seedbox fix
export DOTNET_GCHeapHardLimit=20000000
# Basic Configuration
export TZ=Europe/Berlin
# Sonarr Configuration
export SONARR__ENABLED=true
export SONARR__HOST=https://name.server.usbx.me/sonarr/
export SONARR__APIKEY=APIKEY
# Radarr Configuration
export RADARR__ENABLED=false
export RADARR__HOST=http://localhost:7878
export RADARR__APIKEY=APIKEY
# Readarr Configuration
export READARR__ENABLED=false
export READARR__HOST=http://localhost:8787
export READARR__APIKEY=APIKEY
# Lidarr Configuration
export LIDARR__ENABLED=false
export LIDARR__HOST=http://localhost:8686
export LIDARR__APIKEY=APIKEY
# Multiple Sonarr Instances (commented out by default)
#export SONARR__0__NAME="NAME 1"
#export SONARR__0__ENABLED=false
#export SONARR__0__HOST=http://localhost:8989
#export SONARR__0__APIKEY=APIKEY
#export SONARR__1__NAME="NAME 2"
#export SONARR__1__ENABLED=false
#export SONARR__1__HOST=http://localhost:8989
#export SONARR__1__APIKEY=APIKEY
# Advanced Options
#export IpLeakTest__Enabled=false
#export SETTINGS__IndexerRequestsCacheDurationInMinutes=12
export SETTINGS__ApiKey="apikey" # Change to something unique! Then in Prowlarr, in the proxy settings set any username and use this ApiKey as password.
export SETTINGS__ProxyPort=1234 # Port for Proxy
export Kestrel__Endpoints__Http__Url="http://[::]:1235" # Port for UmlautAdaptarr API
chmod +x ./publish/UmlautAdaptarr
./publish/UmlautAdaptarr