85 Commits
v0.5 ... v0.7

Author SHA1 Message Date
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
Jonas F
b8539b109e Merge pull request #39 from PCJones/develop
Release 0.6
2024-10-11 19:47:24 +02:00
Jonas F
4e030168ee Update docker-compose.yml 2024-10-11 19:47:08 +02:00
Jonas F
5487009306 Update README.md 2024-10-11 19:24:41 +02:00
Jonas F
fc7c0bde28 Add star history to readme 2024-10-11 18:36:17 +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
pcjones
fde9b0a5de Remove unnecessary import 2024-09-30 14:03:36 +02:00
pcjones
94b2cf94c4 Don't spam the log with debug info 2024-09-30 14:03:18 +02:00
pcjones
4a628f7c66 Add missing StringComparison.OrdinalIgnoreCase 2024-09-30 14:02:30 +02:00
pcjones
30e1d3aa11 Fix season matching pattern to match up to 4 digit seasons/episodes 2024-09-22 21:14:35 +02:00
pcjones
5e479661fb Fix reachable check 2024-09-10 18:01:34 +02:00
pcjones
4be90e74b3 Fix reachable check for ultra cc seedbox 2024-09-10 17:52:36 +02:00
pcjones
fcf85a5ad1 AllowAutoRedirect for BeReachable check 2024-09-10 17:38:44 +02:00
pcjones
abff4953e8 Read port from appsettings 2024-09-10 17:08:35 +02:00
Jonas F
d9087e2fe5 Remove proxy environment variables from compose 2024-09-06 20:46:50 +02:00
pcjones
bdd77e11f8 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-09-05 14:24:48 +02:00
pcjones
54fe1c0f89 Remove proxy support 2024-09-05 14:24:42 +02:00
Jonas F
706199074d Update README.md 2024-09-04 20:04:42 +02:00
pcjones
b8575831bd Fix appsettings.json example 2024-09-04 19:40:02 +02:00
pcjones
370e3ca06b Fix warnings 2024-09-04 19:39:15 +02:00
pcjones
238bd9cc60 Code cleanup 2024-09-04 19:30:31 +02:00
Jonas F
4db26e374f Update README.md 2024-09-04 19:17:13 +02:00
Jonas F
ce74044b9b Update README.md 2024-09-04 19:16:37 +02:00
Jonas F
202c09c739 Merge pull request #32 from PCJones/master
Update README.md
2024-09-04 19:16:04 +02:00
Jonas F
c5440fd6c5 Merge pull request #23 from xpsony/multiInstance_serilog
Multi *Arr Instance Support , Add Serilog for better logging, Add Fluent Validator
2024-09-04 19:14:53 +02:00
pcjones
f886b17164 Add FixBadReleaseNaming base concept 2024-09-04 19:00:58 +02:00
Jonas F
40f2131196 Update README.md 2024-09-04 18:31:03 +02:00
pcjones
fd6a8581d8 Also search for "GERMAN" if title ends with "Germany" and also match for title without "Germany" 2024-09-04 18:06:51 +02:00
Felix Glang
74104c300e Create IpInfo.cs
Move To Model
2024-06-09 13:40:47 +02:00
Felix Glang
42554d255e Add IP Infos in Startup
Now User can simply see , if his VPN is working correctly
2024-06-09 13:38:06 +02:00
Felix Glang
c7b92974f4 Update GlobalInstanceOptionsValidator.cs
Add Max Timeout
2024-06-09 12:34:55 +02:00
Felix Glang
265c098630 Fix BeReachable 2024-06-09 12:27:26 +02:00
xpsony
d892f9014e Merge branch 'PCJones:master' into multiInstance_serilog 2024-05-23 09:49:35 +02:00
pcjones
759e276311 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-05-15 20:40:22 +02:00
pcjones
b44c294782 Fix NoSpecialCharactersExceptHyphenAndUmlautsRegex not matching any special characters 2024-05-15 20:40:18 +02:00
Jonas F
26b030808f Merge pull request #25 from PCJones/PCJones-patch-1
Fix readme link
2024-05-12 13:34:08 +02:00
Jonas F
b5fa4308ea Merge pull request #24 from PCJones/PCJones-patch-1
Fix readme link
2024-05-12 13:33:42 +02:00
Jonas F
dea00feaee Update README.md 2024-05-12 13:33:04 +02:00
Felix Glang
ef7182888b Update GlobalInstanceOptionsValidator.cs
Cleanup Code
2024-04-29 20:35:18 +02:00
Felix Glang
5931fd6a8a Fix Bug
If UmlautAdaparr was started before the *Arr. The BeReachable test failed, although the config was correct. Now it is tested every 15 seconds for 3 minutes whether the corresponding application can be reached. Before the test fails
2024-04-29 20:21:46 +02:00
Felix Glang
c788e0ed76 Fix Log 2024-04-28 13:21:48 +02:00
Felix Glang
0bb480b1d0 Add Config Validator + Bug Fixing 2024-04-28 12:59:44 +02:00
Felix Glang
f73b3b5578 Fix in IOptions Copy Section 2024-04-27 21:53:12 +02:00
Felix Glang
e6173ae683 Add Example for Name 2024-04-27 21:29:23 +02:00
Felix Glang
52acb5ff6e Fix named IOption Bug
Fix  named IOption Bug
2024-04-27 21:27:04 +02:00
xpsony
1a32cc325c Merge branch 'PCJones:master' into multiInstance_serilog 2024-04-27 18:50:41 +02:00
Felix Glang
f06a866a2f Add Multi Instance Support , Serilog , little Hotfixes 2024-04-27 18:48:43 +02:00
Jonas F
90c849de52 Merge pull request #20 from PCJones/develop
Add port mapping
2024-04-25 15:30:14 +02:00
Jonas F
176b0a74a6 Update port mapping comment 2024-04-24 15:24:14 +02:00
Jonas F
ffcc8fddcd Add port mapping 2024-04-24 15:21:12 +02:00
43 changed files with 1749 additions and 972 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

@@ -1,22 +1,22 @@
# UmlautAdaptarr
## English description coming soon
A tool to work around Sonarr, Radarr, Lidarr and Readarrs problems with foreign languages.
## Detailed English description coming soon
## Beschreibung
Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen!
Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr, Readarr und Lidarr schon Auswirkungen (abgesehen vom Caching).
UmlautAdaptarr löst mehrere Probleme:
- Releases mit Umlauten werden grundsätzlich nicht korrekt von den *Arrs importiert
- Releases mit Umlauten werden oft nicht korrekt gefunden (*Arrs suchen nach "o" statt "ö" & es fehlt häufig die korrekte Zuordnung zur Serie/zum Film beim Indexer)
- Releases mit Umlauten werden grundsätzlich nicht korrekt von den *arrs importiert
- Releases mit Umlauten werden oft nicht korrekt gefunden (*arrs suchen nach "o" statt "ö" & es fehlt häufig die korrekte Zuordnung zur Serie/zum Film beim Indexer)
- Sonarr & Radarr erwarten immer den englischen Titel von https://thetvdb.com/ bzw. https://www.themoviedb.org/. Das führt bei deutschen Produktionen oder deutschen Übersetzungen oft zu Problemen - falls die *arrs schon mal etwas mit der Meldung `Found matching series/movie via grab history, but release was matched to series by ID. Automatic import is not possible/` nicht importiert haben, dann war das der Grund.
- Releases mit schlechtem Naming (z.B. von der Group TvR die kein "GERMAN" in den Releasename tun) werden korrigiert, so dass Sonarr&Radarr diese korrekt erkennen (optional)
- Zusätzlich werden einige andere Fehler behoben, die häufig dazu führen, dass Titel nicht erfolgreich gefunden, geladen oder importiert werden.
## Wie macht UmlautAdaptarr das?
UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten.
Am Ende werden die gefundenen Releases immer so umbenannt, dass die Arrs sie einwandfrei erkennen.
Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAdaptarr/edit/develop/README.md#beispiel-funktionalit%C3%A4t).
Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAdaptarr?tab=readme-ov-file#beispiel-funktionalit%C3%A4t).
## Features
@@ -30,11 +30,13 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda
| Releases mit deutschem Titel werden erkannt |✓ |
| Releases mit TVDB-Alias Titel werden erkannt |✓ |
| Korrekte Suche und Erkennung von Titel mit Umlauten |✓ |
| Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ |
| Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriff |✓ |
| Usenet (newznab) Support |✓ |
| Torrent (torznab) Support |✓ |
| Radarr Support | Geplant |
| Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant |
| Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr)|✓ |
| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit|
| Radarr Support | in Arbeit |
| Webinterface | Geplant |
| Unterstützung weiterer Sprachen neben Deutsch | Geplant |
| Wünsche? | Vorschläge? |
@@ -44,13 +46,14 @@ Momentan ist Docker dafür nötig, wer kein Docker nutzt muss sich noch etwas ge
[Link zum Docker Image](https://hub.docker.com/r/pcjones/umlautadaptarr)
Nicht benötigte Umgebungsvariablen, z.B. wenn Readarr oder Lidarr nicht benötigt werden, können entfernt werden.
Nicht benötigte Umgebungsvariablen, z.B. falls Readarr oder Lidarr nicht genutzt werden, können entfernt werden.
### Konfiguration in Prowlarr (**empfohlen**)
Das ist die **empfohlene** Methode um den UmlautAdaptarr einzurichten. Sie hat den Vorteil, dass es, sofern man mehrere Indexer nutzt, keinen Geschwindigkeitsverlust bei der Suche geben sollte.
1) In Prowlarr: Settings>Indexers bzw. Einstellungen>Indexer öffnen
2) Lege einen neuen HTTP-Proxy an:
1) Setze die benötigten [Docker Umgebungsvariablen](https://hub.docker.com/r/pcjones/umlautadaptarr) in deiner docker-compose Datei bzw. in deinem docker run Befehl
2) In Prowlarr: Settings>Indexers bzw. Einstellungen>Indexer öffnen
3) Lege einen neuen HTTP-Proxy an:
![Image](https://github.com/PCJones/UmlautAdaptarr/assets/377223/b97418d8-d972-4e3c-9d2f-3a830a5ac0a3)
@@ -59,19 +62,20 @@ Das ist die **empfohlene** Methode um den UmlautAdaptarr einzurichten. Sie hat d
- Tag: `umlautadaptarr`
- Host: Je nachdem, wie deine Docker-Konfiguration ist, kann es sein, dass du entweder `umlautadaptarr` oder `localhost`, oder ggf. die IP des Host setzen musst. Probiere es sonst einfach aus, indem du auf Test klickst.
- Die Username- und Passwort-Felder können leergelassen werden.
3) Gehe zur Indexer-Übersichtsseite
4) Für alle Indexer/Tracker, die den UmlautAdaptarr nutzen sollen:
4) Gehe zur Indexer-Übersichtsseite
5) Für alle Indexer/Tracker, die den UmlautAdaptarr nutzen sollen:
![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/3daea3f1-7c7b-4982-84e2-ea6a42d90fba)
- Füge den `umlautadaptarr` Tag hinzu
- **Wichtig:** Ändere die URL von `https` zu `http`. (Dies ist erforderlich, damit der UmlautAdaptarr die Anfragen **lokal** abfangen kann. **Ausgehende** Anfragen an den Indexer verwenden natürlich weiterhin https).
5) Klicke danach auf `Test All Indexers` bzw `Alle Indexer Testen`. Falls du irgendwo noch `https` statt `http` stehen hast, sollte in den UmlautAdaptarr logs eine Warnung auftauchen. Mindestens solltest du aber noch ein zweites Mal alle Indexer durchgehen und überprüfen, ob überall `http` eingestellt ist - Indexer, bei denen noch `https` steht, werden nämlich einwandfrei funktionieren - allerdings ohne, dass der UmlautAdaptarr bei diesen wirken kann.
6) Klicke danach auf `Test All Indexers` bzw `Alle Indexer Testen`. Falls du irgendwo noch `https` statt `http` stehen hast, sollte in den UmlautAdaptarr logs eine Warnung auftauchen. Mindestens solltest du aber noch ein zweites Mal alle Indexer durchgehen und überprüfen, ob überall `http` eingestellt ist - Indexer, bei denen noch `https` steht, werden nämlich einwandfrei funktionieren - allerdings ohne, dass der UmlautAdaptarr bei diesen wirken kann.
### Konfiguration in Sonarr/Radarr oder Prowlarr ohne Proxy
Falls du kein Prowlarr nutzt oder nur 1-3 Indexer nutzt, kannst du diese alternative Konfigurationsmöglichkeit nutzen.
Dafür musst du einfach nur alle Indexer, bei denen der UmlautAdaptarr greifen soll, bearbeiten:
1) Setze die benötigten [Docker Umgebungsvariablen](https://hub.docker.com/r/pcjones/umlautadaptarr) in deiner docker-compose Datei bzw. in deinem docker run Befehl
2) Bearbeite alle Indexer, bei denen der UmlautAdaptarr greifen soll, wie folgt:
Am Beispiel von sceneNZBs:
@@ -128,3 +132,7 @@ Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke
- TV Metadata source: https://thetvdb.com
- Movie Metadata source: https://themoviedb.org
- Licenses: TODO
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=pcjones/umlautadaptarr&type=Date)](https://star-history.com/#pcjones/umlautadaptarr&Date)

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 (_options.ApiKey != null && !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,29 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Providers;
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.");
}
var initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult;
ContentResult? initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult;
if (initialSearchResult == null)
{
return null;
@@ -109,9 +117,17 @@ namespace UmlautAdaptarr.Controllers
private string ProcessContent(string content, SearchItem? 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(
string domain,
@@ -150,27 +166,48 @@ namespace UmlautAdaptarr.Controllers
return aggregatedResult;
}
internal bool AssureApiKey(string apiKey)
{
if (options.Value.ApiKey != null && !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(
q => q.Key,
@@ -198,21 +235,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 +276,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,10 @@
using UmlautAdaptarr.Models;
namespace UmlautAdaptarr.Interfaces;
public interface IArrApplication
{
Task<IEnumerable<SearchItem>> FetchAllItemsAsync();
Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
Task<SearchItem?> FetchItemByTitleAsync(string title);
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace UmlautAdaptarr.Utilities;
public class IpInfo
{
[JsonPropertyName("ip")]
public string? Ip { get; set; }
[JsonPropertyName("hostname")]
public string? Hostname { get; set; }
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("region")]
public string? Region { get; set; }
[JsonPropertyName("country")]
public string? Country { get; set; }
[JsonPropertyName("loc")]
public string? Loc { get; set; }
[JsonPropertyName("org")]
public string? Org { get; set; }
[JsonPropertyName("postal")]
public string? Postal { get; set; }
[JsonPropertyName("timezone")]
public string? Timezone { get; set; }
}

View File

@@ -108,6 +108,20 @@ namespace UmlautAdaptarr.Models
}
// if a german title ends with "Germany" (e.g. Good Luck Guys Germany) also add a search string that replaces Germany with GERMAN
// (e.g. Good Luck Guys GERMAN). This is because reality shows often have different formats in different countries with the same
// name. // also add a matching title without GERMAN
if (germanTitle?.EndsWith("germany", StringComparison.OrdinalIgnoreCase) ?? false)
{
TitleSearchVariations = [.. TitleSearchVariations,
..
GenerateVariations(
(germanTitle[..^7] + "GERMAN").RemoveExtraWhitespaces(),
mediaType)];
allTitleVariations.AddRange(GenerateVariations(germanTitle[..^8].Trim(), mediaType));
}
// If title contains ":" also match for "-"
if (germanTitle?.Contains(':') ?? false)
{
@@ -152,7 +166,7 @@ namespace UmlautAdaptarr.Models
}
}
private IEnumerable<string> GenerateVariations(string? title, string mediaType)
private static IEnumerable<string> GenerateVariations(string? title, string mediaType)
{
if (title == null)
{

View File

@@ -1,15 +1,17 @@
namespace UmlautAdaptarr.Options.ArrOptions
namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions
{
/// <summary>
/// Base Options for ARR applications
/// </summary>
public class ArrApplicationBaseOptions
public class GlobalInstanceOptions
{
/// <summary>
/// Indicates whether the Arr application is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Name of the Instance
/// </summary>
public string Name { get; set; }
/// <summary>
/// The host of the ARR application.
/// </summary>

View File

@@ -0,0 +1,6 @@
namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
public class LidarrInstanceOptions : GlobalInstanceOptions
{
}

View File

@@ -0,0 +1,6 @@
namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
public class ReadarrInstanceOptions : GlobalInstanceOptions
{
}

View File

@@ -0,0 +1,6 @@
namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
public class SonarrInstanceOptions : GlobalInstanceOptions
{
}

View File

@@ -1,9 +0,0 @@
namespace UmlautAdaptarr.Options.ArrOptions
{
/// <summary>
/// Lidarr Options
/// </summary>
public class LidarrInstanceOptions : ArrApplicationBaseOptions
{
}
}

View File

@@ -1,9 +0,0 @@
namespace UmlautAdaptarr.Options.ArrOptions
{
/// <summary>
/// Readarr Options
/// </summary>
public class ReadarrInstanceOptions : ArrApplicationBaseOptions
{
}
}

View File

@@ -1,9 +0,0 @@
namespace UmlautAdaptarr.Options.ArrOptions
{
/// <summary>
/// Sonarr Options
/// </summary>
public class SonarrInstanceOptions : ArrApplicationBaseOptions
{
}
}

View File

@@ -14,5 +14,18 @@
/// 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.
public string? ApiKey { get; set; } = null;
/// <summary>
/// Proxy port for the internal UmlautAdaptarr proxy.
public int ProxyPort { get; set; } = 5006;
}
}

View File

@@ -1,27 +0,0 @@
namespace UmlautAdaptarr.Options;
/// <summary>
/// Represents options for proxy configuration.
/// </summary>
public class Proxy
{
/// <summary>
/// Gets or sets a value indicating whether to use a proxy.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the address of the proxy.
/// </summary>
public string? Address { get; set; }
/// <summary>
/// Gets or sets the username for proxy authentication.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Gets or sets the password for proxy authentication.
/// </summary>
public string? Password { get; set; }
}

View File

@@ -1,32 +0,0 @@
namespace UmlautAdaptarr.Options;
/// <summary>
/// Represents options for proxy configuration.
/// </summary>
public class ProxyOptions
{
/// <summary>
/// Gets or sets a value indicating whether to use a proxy.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the address of the proxy.
/// </summary>
public string? Address { get; set; }
/// <summary>
/// Gets or sets the username for proxy authentication.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Gets or sets the password for proxy authentication.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Bypass Local Ip Addresses , Proxy will ignore local Ip Addresses
/// </summary>
public bool BypassOnLocal { get; set; }
}

View File

@@ -1,31 +1,37 @@
using System.Net;
using UmlautAdaptarr.Options;
using Serilog;
using Serilog.Filters;
using UmlautAdaptarr.Routing;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Services.Factory;
using UmlautAdaptarr.Utilities;
internal class Program
{
private static void Main(string[] args)
{
MainAsync(args).Wait();
}
private static async Task MainAsync(string[] args)
{
// TODO:
// add option to sort by nzb age
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
ConfigureLogger(configuration);
builder.Services.AddSerilog();
// Add services to the container.
builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate |
DecompressionMethods.Brotli
};
var proxyOptions = configuration.GetSection("Proxy").Get<ProxyOptions>();
handler.ConfigureProxy(proxyOptions);
return handler;
});
@@ -35,66 +41,82 @@ internal class Program
//options.SizeLimit = 20000;
});
// TODO workaround to not log api keys
builder.Logging.AddFilter((category, level) =>
{
// Prevent logging of HTTP request and response if the category is HttpClient
if (category.Contains("System.Net.Http.HttpClient") || category.Contains("Microsoft.Extensions.Http.DefaultHttpClientFactory"))
{
return false;
}
return true;
});
builder.Services.AllowResolvingKeyedServicesAsDictionary();
builder.Services.AddControllers();
builder.Services.AddHostedService<ArrSyncBackgroundService>();
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>();
builder.Services.AddHostedService<ArrSyncBackgroundService>();
builder.Services.AddSingleton<IHostedService, HttpProxyService>();
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(name: "caps",
pattern: "{options}/{*domain}",
defaults: new { controller = "Caps", action = "Caps" },
constraints: new { t = new TRouteConstraint("caps") });
app.MapControllerRoute("caps",
"{apiKey}/{*domain}",
new { controller = "Caps", action = "Caps" },
new { t = new TRouteConstraint("caps") });
app.MapControllerRoute(name: "movie-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "MovieSearch" },
constraints: new { t = new TRouteConstraint("movie") });
app.MapControllerRoute("movie-search",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MovieSearch" },
new { t = new TRouteConstraint("movie") });
app.MapControllerRoute(name: "tv-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "TVSearch" },
constraints: new { t = new TRouteConstraint("tvsearch") });
app.MapControllerRoute("tv-search",
"{apiKey}/{*domain}",
new { controller = "Search", action = "TVSearch" },
new { t = new TRouteConstraint("tvsearch") });
app.MapControllerRoute(name: "music-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "MusicSearch" },
constraints: new { t = new TRouteConstraint("music") });
app.MapControllerRoute("music-search",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MusicSearch" },
new { t = new TRouteConstraint("music") });
app.MapControllerRoute(name: "book-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "BookSearch" },
constraints: new { t = new TRouteConstraint("book") });
app.MapControllerRoute(name: "generic-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "GenericSearch" },
constraints: new { t = new TRouteConstraint("search") });
app.MapControllerRoute("book-search",
"{apiKey}/{*domain}",
new { controller = "Search", action = "BookSearch" },
new { t = new TRouteConstraint("book") });
app.MapControllerRoute("generic-search",
"{apiKey}/{*domain}",
new { controller = "Search", action = "GenericSearch" },
new { t = new TRouteConstraint("search") });
app.Run();
}
private static void ConfigureLogger(ConfigurationManager configuration)
{
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.WriteTo.Console(outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
#if RELEASE
.Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Mvc"))
.Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Routing"))
.Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Diagnostics"))
.Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Hosting"))
#endif
// TODO workaround to not log api keys
.Filter.ByExcluding(Matching.FromSource("System.Net.Http.HttpClient"))
.Filter.ByExcluding(Matching.FromSource("Microsoft.Extensions.Http.DefaultHttpClientFactory"))
//.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // TODO - Not working currently
.CreateLogger();
}
}

View File

@@ -3,7 +3,8 @@
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
"SETTINGS__ApiKey": "test123"
},
"_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100",
"dotnetRunMessages": true,

View File

@@ -1,13 +1,14 @@
using Microsoft.Extensions.Caching.Memory;
using UmlautAdaptarr.Interfaces;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Services;
namespace UmlautAdaptarr.Providers
{
public abstract class ArrClientBase()
namespace UmlautAdaptarr.Providers;
public abstract class ArrClientBase : IArrApplication
{
#pragma warning disable CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Erwägen Sie die Deklaration als Nullable.
public string InstanceName;
#pragma warning restore CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Erwägen Sie die Deklaration als Nullable.
public abstract Task<IEnumerable<SearchItem>> FetchAllItemsAsync();
public abstract Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
public abstract Task<SearchItem?> FetchItemByTitleAsync(string title);
}
}

View File

@@ -1,17 +0,0 @@
namespace UmlautAdaptarr.Providers
{
public static class ArrClientFactory
{
// TODO, still uses old IConfiguration
// TODO not used yet
public static IEnumerable<TClient> CreateClients<TClient>(
Func<string, TClient> constructor, IConfiguration configuration, string configKey) where TClient : ArrClientBase
{
var hosts = configuration.GetValue<string>(configKey)?.Split(',') ?? throw new ArgumentException($"{configKey} environment variable must be set if the app is enabled");
foreach (var host in hosts)
{
yield return constructor(host.Trim());
}
}
}
}

View File

@@ -1,46 +1,64 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers
namespace UmlautAdaptarr.Providers;
public class LidarrClient : ArrClientBase
{
public class LidarrClient(
private readonly IMemoryCache _cache;
private readonly CacheService _cacheService;
private readonly IHttpClientFactory _clientFactory;
private readonly ILogger<LidarrClient> _logger;
private readonly string _mediaType = "audio";
public LidarrClient([ServiceKey] string instanceName,
IHttpClientFactory clientFactory,
CacheService cacheService,
IMemoryCache cache,
ILogger<LidarrClient> logger, IOptions<LidarrInstanceOptions> options) : ArrClientBase()
IMemoryCache cache, IOptionsMonitor<LidarrInstanceOptions> options,
ILogger<LidarrClient> logger)
{
public LidarrInstanceOptions LidarrOptions { get; } = options.Value;
private readonly string _mediaType = "audio";
_clientFactory = clientFactory;
_cacheService = cacheService;
_cache = cache;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init Lidarr ({InstanceName})");
}
public LidarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = clientFactory.CreateClient();
var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>();
try
{
var lidarrArtistsUrl = $"{LidarrOptions.Host}/api/v1/artist?apikey={LidarrOptions.ApiKey}";
logger.LogInformation($"Fetching all artists from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
var lidarrArtistsUrl = $"{Options.Host}/api/v1/artist?apikey={Options.ApiKey}";
_logger.LogInformation(
$"Fetching all artists from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl);
var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse);
if (artists == null)
{
logger.LogError($"Lidarr artists API request resulted in null");
_logger.LogError($"Lidarr ({InstanceName}) artists API request resulted in null");
return items;
}
logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr.");
_logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr ({InstanceName}).");
foreach (var artist in artists)
{
var artistId = (int)artist.id;
var lidarrAlbumUrl = $"{LidarrOptions.Host}/api/v1/album?artistId={artistId}&apikey={LidarrOptions.ApiKey}";
var lidarrAlbumUrl = $"{Options.Host}/api/v1/album?artistId={artistId}&apikey={Options.ApiKey}";
// TODO add caching here
// Disable cache for now as it can result in problems when adding new albums that aren't displayed on the artists page initially
@@ -50,21 +68,24 @@ namespace UmlautAdaptarr.Providers
//}
//else
//{
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
_logger.LogInformation(
$"Fetching all albums from artistId {artistId} from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
//}
if (albums == null)
{
logger.LogWarning($"Lidarr album API request for artistId {artistId} resulted in null");
_logger.LogWarning(
$"Lidarr ({InstanceName}) album API request for artistId {artistId} resulted in null");
continue;
}
logger.LogInformation($"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr.");
_logger.LogInformation(
$"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr ({InstanceName}).");
// Cache albums for 3 minutes
cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3));
_cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3));
foreach (var album in albums)
{
@@ -80,11 +101,11 @@ namespace UmlautAdaptarr.Providers
var searchItem = new SearchItem
(
arrId: artistId,
externalId: externalId,
title: albumTitle,
expectedTitle: albumTitle,
germanTitle: null,
artistId,
externalId,
albumTitle,
albumTitle,
null,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: artistName
@@ -94,11 +115,11 @@ namespace UmlautAdaptarr.Providers
}
}
logger.LogInformation($"Finished fetching all items from Lidarr");
_logger.LogInformation($"Finished fetching all items from Lidarr ({InstanceName})");
}
catch (Exception ex)
{
logger.LogError($"Error fetching all artists from Lidarr: {ex.Message}");
_logger.LogError($"Error fetching all artists from Lidarr ({InstanceName}) : {ex.Message}");
}
return items;
@@ -112,20 +133,19 @@ namespace UmlautAdaptarr.Providers
// TODO if possible look at the author in search query and only update for author
var searchItems = await FetchAllItemsAsync();
foreach (var searchItem in searchItems ?? [])
{
try
{
cacheService.CacheSearchItem(searchItem);
_cacheService.CacheSearchItem(searchItem);
}
catch (Exception ex)
{
logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}.");
}
_logger.LogError(ex,
$"An error occurred while caching search item with ID {searchItem.ArrId} in Lidarr ({InstanceName}).");
}
}
catch (Exception ex)
{
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
_logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}) : {ex.Message}");
}
return null;
@@ -140,10 +160,9 @@ namespace UmlautAdaptarr.Providers
}
catch (Exception ex)
{
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}");
_logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}): {ex.Message}");
}
return null;
}
}
}

View File

@@ -1,64 +1,82 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers
namespace UmlautAdaptarr.Providers;
public class ReadarrClient : ArrClientBase
{
public class ReadarrClient(
IHttpClientFactory clientFactory,
private readonly IMemoryCache _cache;
private readonly CacheService _cacheService;
private readonly IHttpClientFactory _clientFactory;
private readonly ILogger<ReadarrClient> _logger;
private readonly string _mediaType = "book";
public ReadarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory,
CacheService cacheService,
IMemoryCache cache,
IOptions<ReadarrInstanceOptions> options,
ILogger<ReadarrClient> logger) : ArrClientBase()
IOptionsMonitor<ReadarrInstanceOptions> options,
ILogger<ReadarrClient> logger)
{
_clientFactory = clientFactory;
_cacheService = cacheService;
_cache = cache;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init ReadarrClient ({InstanceName})");
}
public ReadarrInstanceOptions ReadarrOptions { get; } = options.Value;
private readonly string _mediaType = "book";
public ReadarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = clientFactory.CreateClient();
var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>();
try
{
var readarrAuthorUrl = $"{ReadarrOptions.Host}/api/v1/author?apikey={ReadarrOptions.ApiKey}";
logger.LogInformation($"Fetching all authors from Readarr: {UrlUtilities.RedactApiKey(readarrAuthorUrl)}");
var readarrAuthorUrl = $"{Options.Host}/api/v1/author?apikey={Options.ApiKey}";
_logger.LogInformation(
$"Fetching all authors from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrAuthorUrl)}");
var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl);
var authors = JsonConvert.DeserializeObject<List<dynamic>>(authorApiResponse);
if (authors == null)
{
logger.LogError($"Readarr authors API request resulted in null");
_logger.LogError($"Readarr ({InstanceName}) authors API request resulted in null");
return items;
}
logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr.");
_logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr ({InstanceName}).");
foreach (var author in authors)
{
var authorId = (int)author.id;
var readarrBookUrl = $"{ReadarrOptions.Host}/api/v1/book?authorId={authorId}&apikey={ReadarrOptions.ApiKey}";
var readarrBookUrl = $"{Options.Host}/api/v1/book?authorId={authorId}&apikey={Options.ApiKey}";
// TODO add caching here
logger.LogInformation($"Fetching all books from authorId {authorId} from Readarr: {UrlUtilities.RedactApiKey(readarrBookUrl)}");
_logger.LogInformation(
$"Fetching all books from authorId {authorId} from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrBookUrl)}");
var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl);
var books = JsonConvert.DeserializeObject<List<dynamic>>(bookApiResponse);
if (books == null)
{
logger.LogWarning($"Readarr book API request for authorId {authorId} resulted in null");
_logger.LogWarning(
$"Readarr ({InstanceName}) book API request for authorId {authorId} resulted in null");
continue;
}
logger.LogInformation($"Successfully fetched {books.Count} books for authorId {authorId} from Readarr.");
_logger.LogInformation(
$"Successfully fetched {books.Count} books for authorId {authorId} from Readarr ({InstanceName}) .");
// Cache books for 3 minutes
cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3));
_cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3));
foreach (var book in books)
{
@@ -75,11 +93,11 @@ namespace UmlautAdaptarr.Providers
var searchItem = new SearchItem
(
arrId: authorId,
externalId: externalId,
title: bookTitle,
expectedTitle: bookTitle,
germanTitle: null,
authorId,
externalId,
bookTitle,
bookTitle,
null,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: authorName
@@ -89,11 +107,11 @@ namespace UmlautAdaptarr.Providers
}
}
logger.LogInformation($"Finished fetching all items from Readarr");
_logger.LogInformation($"Finished fetching all items from Readarr ({InstanceName})");
}
catch (Exception ex)
{
logger.LogError($"Error fetching all authors from Readarr: {ex.Message}");
_logger.LogError($"Error fetching all authors from Readarr ({InstanceName}): {ex.Message}");
}
return items;
@@ -104,21 +122,18 @@ namespace UmlautAdaptarr.Providers
{
// Remove author prefix from title if present, e.g., "Tom Clancy: Ghost Protocol"
if (!string.IsNullOrEmpty(authorName) && bookTitle.StartsWith($"{authorName}:"))
{
bookTitle = bookTitle[(authorName.Length + 1)..].Trim();
}
// Remove subtitles or additional info enclosed in parentheses or following a colon, if any
int firstParenthesisIndex = bookTitle.IndexOf('(');
int firstColonIndex = bookTitle.IndexOf(':');
var firstParenthesisIndex = bookTitle.IndexOf('(');
var firstColonIndex = bookTitle.IndexOf(':');
if (firstParenthesisIndex > -1)
{
int endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex);
if (endParenthesisIndex > -1 && bookTitle.Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1).Contains(' '))
{
bookTitle = bookTitle[..firstParenthesisIndex].Trim();
}
var endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex);
if (endParenthesisIndex > -1 && bookTitle
.Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1)
.Contains(' ')) bookTitle = bookTitle[..firstParenthesisIndex].Trim();
}
else if (firstColonIndex > -1)
{
@@ -137,20 +152,18 @@ namespace UmlautAdaptarr.Providers
// TODO if possible look at the author in search query and only update for author
var searchItems = await FetchAllItemsAsync();
foreach (var searchItem in searchItems ?? [])
{
try
{
cacheService.CacheSearchItem(searchItem);
_cacheService.CacheSearchItem(searchItem);
}
catch (Exception ex)
{
logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}.");
}
_logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}.");
}
}
catch (Exception ex)
{
logger.LogError($"Error fetching single author from Readarr: {ex.Message}");
_logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}");
}
return null;
@@ -165,10 +178,9 @@ namespace UmlautAdaptarr.Providers
}
catch (Exception ex)
{
logger.LogError($"Error fetching single author from Readarr: {ex.Message}");
_logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}");
}
return null;
}
}
}

View File

@@ -1,53 +1,94 @@
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers
namespace UmlautAdaptarr.Providers;
public class SonarrClient : ArrClientBase
{
public class SonarrClient(
private readonly IHttpClientFactory _clientFactory;
private readonly ILogger<SonarrClient> _logger;
private readonly string _mediaType = "tv";
private readonly TitleApiService _titleService;
public SonarrClient([ServiceKey] string instanceName,
IHttpClientFactory clientFactory,
TitleApiService titleService,
IOptions<SonarrInstanceOptions> options,
ILogger<SonarrClient> logger) : ArrClientBase()
IOptionsMonitor<SonarrInstanceOptions> options,
ILogger<SonarrClient> logger)
{
public SonarrInstanceOptions SonarrOptions { get; } = options.Value;
private readonly string _mediaType = "tv";
_clientFactory = clientFactory;
_titleService = titleService;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init SonarrClient ({InstanceName})");
}
public SonarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = clientFactory.CreateClient();
var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>();
try
{
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var sonarrUrl = $"{Options.Host}/api/v3/series?includeSeasonImages=false&apikey={Options.ApiKey}";
_logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<List<dynamic>>(response);
if (shows != null)
{
logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr.");
_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;
if (tvdbId == null)
{
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId.");
_logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
continue;
}
(var germanTitle, var 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
(
arrId: (int)show.id,
externalId: tvdbId,
title: (string)show.title,
expectedTitle: (string)show.title,
germanTitle: germanTitle,
(int)show.id,
tvdbId,
(string)show.title,
(string)show.title,
germanTitle,
aliases: aliases,
mediaType: _mediaType
);
@@ -56,11 +97,11 @@ namespace UmlautAdaptarr.Providers
}
}
logger.LogInformation($"Finished fetching all items from Sonarr");
_logger.LogInformation($"Finished fetching all items from Sonarr ({InstanceName})");
}
catch (Exception ex)
{
logger.LogError($"Error fetching all shows from Sonarr: {ex.Message}");
_logger.LogError($"Error fetching all shows from Sonarr ({InstanceName}) : {ex.Message}");
}
return items;
@@ -68,12 +109,14 @@ namespace UmlautAdaptarr.Providers
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
{
var httpClient = clientFactory.CreateClient();
var httpClient = _clientFactory.CreateClient();
try
{
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
logger.LogInformation($"Fetching item by external ID from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var sonarrUrl =
$"{Options.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={Options.ApiKey}";
_logger.LogInformation(
$"Fetching item by external ID from Sonarr ({InstanceName}): {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(response);
var show = shows?[0];
@@ -83,29 +126,31 @@ namespace UmlautAdaptarr.Providers
var tvdbId = (string)show.tvdbId;
if (tvdbId == null)
{
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId.");
_logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
return null;
}
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var (germanTitle, aliases) =
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var searchItem = new SearchItem
(
arrId: (int)show.id,
externalId: tvdbId,
title: (string)show.title,
expectedTitle: (string)show.title,
germanTitle: germanTitle,
(int)show.id,
tvdbId,
(string)show.title,
(string)show.title,
germanTitle,
aliases: aliases,
mediaType: _mediaType
);
logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
_logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
return searchItem;
}
}
catch (Exception ex)
{
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}");
_logger.LogError($"Error fetching single show from Sonarr ({InstanceName}): {ex.Message}");
}
return null;
@@ -113,59 +158,58 @@ namespace UmlautAdaptarr.Providers
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
{
var httpClient = clientFactory.CreateClient();
var httpClient = _clientFactory.CreateClient();
try
{
(string? germanTitle, string? tvdbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
var (germanTitle, tvdbId, aliases) =
await _titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
if (tvdbId == null)
{
return null;
}
if (tvdbId == null) return null;
var sonarrUrl = $"{SonarrOptions.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={SonarrOptions.ApiKey}";
var sonarrUrl =
$"{Options.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={Options.ApiKey}";
var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
if (shows == null)
{
logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null");
_logger.LogError($"Parsing Sonarr ({InstanceName}) API response for TVDB ID {tvdbId} resulted in null");
return null;
}
else if (shows.Count == 0)
if (shows.Count == 0)
{
logger.LogWarning($"No results found for TVDB ID {tvdbId}");
_logger.LogWarning($"No results found for TVDB ID {tvdbId}");
return null;
}
var expectedTitle = (string)shows[0].title;
if (expectedTitle == null)
{
logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null");
_logger.LogError($"Sonarr ({InstanceName}) : Title for TVDB ID {tvdbId} is null");
return null;
}
var searchItem = new SearchItem
(
arrId: (int)shows[0].id,
externalId: tvdbId,
title: (string)shows[0].title,
expectedTitle: (string)shows[0].title,
germanTitle: germanTitle,
(int)shows[0].id,
tvdbId,
(string)shows[0].title,
(string)shows[0].title,
germanTitle,
aliases: aliases,
mediaType: _mediaType
);
logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
_logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
return searchItem;
}
catch (Exception ex)
{
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}");
_logger.LogError($"Error fetching single show from Sonarr ({InstanceName}) : {ex.Message}");
}
return null;
}
}
}

View File

@@ -1,27 +1,21 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Threading;
using System.Threading.Tasks;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Services.Factory;
namespace UmlautAdaptarr.Services;
namespace UmlautAdaptarr.Services
{
public class ArrSyncBackgroundService(
SonarrClient sonarrClient,
LidarrClient lidarrClient,
ReadarrClient readarrClient,
ArrApplicationFactory arrApplicationFactory,
CacheService cacheService,
ILogger<ArrSyncBackgroundService> logger) : BackgroundService
ILogger<ArrSyncBackgroundService> logger)
: BackgroundService
{
public ArrApplicationFactory ArrApplicationFactory { get; } = arrApplicationFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("ArrSyncBackgroundService is starting.");
bool lastRunSuccess = true;
var lastRunSuccess = true;
while (!stoppingToken.IsCancellationRequested)
{
@@ -39,12 +33,14 @@ namespace UmlautAdaptarr.Services
if (lastRunSuccess)
{
lastRunSuccess = false;
logger.LogInformation("ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful.");
logger.LogInformation(
"ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful.");
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
else
{
logger.LogInformation("ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row.");
logger.LogInformation(
"ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row.");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
@@ -58,27 +54,34 @@ namespace UmlautAdaptarr.Services
try
{
var success = true;
if (readarrClient.ReadarrOptions.Enabled)
{
var syncSuccess = await FetchItemsFromReadarrAsync();
success = success && syncSuccess;
}
if (sonarrClient.SonarrOptions.Enabled)
if (ArrApplicationFactory.SonarrInstances.Any())
{
var syncSuccess = await FetchItemsFromSonarrAsync();
success = success && syncSuccess;
}
if (lidarrClient.LidarrOptions.Enabled)
if (ArrApplicationFactory.ReadarrInstances.Any())
{
var syncSuccess = await FetchItemsFromReadarrAsync();
success = success && syncSuccess;
}
if (ArrApplicationFactory.ReadarrInstances.Any())
{
var syncSuccess = await FetchItemsFromLidarrAsync();
success = success && syncSuccess;
}
return success;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while fetching items from the Arrs.");
}
return false;
}
@@ -86,7 +89,15 @@ namespace UmlautAdaptarr.Services
{
try
{
var items = await sonarrClient.FetchAllItemsAsync();
var items = new List<SearchItem>();
foreach (var sonarrClient in ArrApplicationFactory.SonarrInstances)
{
var result = await sonarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items);
return items?.Any() ?? false;
}
@@ -94,6 +105,7 @@ namespace UmlautAdaptarr.Services
{
logger.LogError(ex, "An error occurred while updating search item from Sonarr.");
}
return false;
}
@@ -101,7 +113,14 @@ namespace UmlautAdaptarr.Services
{
try
{
var items = await lidarrClient.FetchAllItemsAsync();
var items = new List<SearchItem>();
foreach (var lidarrClient in ArrApplicationFactory.LidarrInstances)
{
var result = await lidarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items);
return items?.Any() ?? false;
}
@@ -109,6 +128,7 @@ namespace UmlautAdaptarr.Services
{
logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
}
return false;
}
@@ -116,7 +136,14 @@ namespace UmlautAdaptarr.Services
{
try
{
var items = await readarrClient.FetchAllItemsAsync();
var items = new List<SearchItem>();
foreach (var readarrClient in ArrApplicationFactory.ReadarrInstances)
{
var result = await readarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items);
return items?.Any() ?? false;
}
@@ -124,13 +151,13 @@ namespace UmlautAdaptarr.Services
{
logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
}
return false;
}
private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems)
{
foreach (var searchItem in searchItems ?? [])
{
try
{
cacheService.CacheSearchItem(searchItem);
@@ -141,5 +168,3 @@ namespace UmlautAdaptarr.Services
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
using UmlautAdaptarr.Interfaces;
using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services.Factory
{
/// <summary>
/// Factory for creating ArrApplication instances.
/// </summary>
public class ArrApplicationFactory
{
private readonly ILogger<ArrApplicationFactory> _logger;
/// <summary>
/// Get all IArrApplication instances.
/// </summary>
public IDictionary<string, IArrApplication> AllInstances { get; init; }
/// <summary>
/// Get all SonarrClient instances.
/// </summary>
public IEnumerable<SonarrClient> SonarrInstances { get; init; }
/// <summary>
/// Get all LidarrClient instances.
/// </summary>
public IEnumerable<LidarrClient> LidarrInstances { get; init; }
/// <summary>
/// Get all ReadarrClient instances.
/// </summary>
public IEnumerable<ReadarrClient> ReadarrInstances { get; init; }
/// <summary>
/// Constructor for the ArrApplicationFactory.
/// </summary>
/// <param name="arrApplications">A dictionary of IArrApplication instances.</param>
/// <param name="logger">Logger Instanz</param>
public ArrApplicationFactory(IDictionary<string, IArrApplication> arrApplications, ILogger<ArrApplicationFactory> logger)
{
_logger = logger;
try
{
SonarrInstances = arrApplications.Values.OfType<SonarrClient>();
LidarrInstances = arrApplications.Values.OfType<LidarrClient>();
ReadarrInstances = arrApplications.Values.OfType<ReadarrClient>();
AllInstances = arrApplications;
if (AllInstances.Values.Count == 0)
{
throw new Exception("No ArrApplication could be successfully initialized. This could be due to a faulty configuration");
}
}
catch (Exception e)
{
_logger.LogError("Error while registering ArrFactory. This is most likely a config problem, please check your environment variables.", e.Message);
throw;
}
}
/// <summary>
/// Returns an IArrApplication instance that matches the given name.
/// </summary>
/// <param name="nameOfArrInstance">The name of the IArrApplication instance being sought.</param>
/// <returns>The IArrApplication instance that matches the given name.</returns>
/// <exception cref="ArgumentException">Thrown when no IArrApplication instance with the given name can be found.</exception>
public IArrApplication GetArrInstanceByName(string nameOfArrInstance)
{
var instance = AllInstances.FirstOrDefault(up => up.Key.Equals(nameOfArrInstance)).Value;
if (instance == null)
{
throw new ArgumentException($"No ArrService with the name {nameOfArrInstance} could be found");
}
return instance;
}
}
}

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,18 @@ 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 HashSet<string> _knownHosts = [];
private readonly object _hostsLock = new object();
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)
public HttpProxyService(ILogger<HttpProxyService> logger, IHttpClientFactory clientFactory, IConfiguration configuration, IOptions<GlobalOptions> options)
{
_options = options.Value;
_logger = logger;
_configuration = configuration;
_clientFactory = clientFactory;
_knownHosts.Add("prowlarr.servarr.com");
}
@@ -34,9 +39,27 @@ namespace UmlautAdaptarr.Services
{
using var clientStream = new NetworkStream(clientSocket, ownsSocket: true);
var buffer = new byte[8192];
var bytesRead = await clientStream.ReadAsync(buffer, 0, buffer.Length);
var bytesRead = await clientStream.ReadAsync(buffer);
var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead);
if (_options.ApiKey != null)
{
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
@@ -49,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);
@@ -91,7 +127,12 @@ namespace UmlautAdaptarr.Services
}
}
var modifiedUri = $"http://localhost:5005/_/{uri.Host}{uri.PathAndQuery}"; // TODO read port from appsettings?
var url = _configuration["Kestrel:Endpoints:Http:Url"];
var port = new Uri(url).Port;
var apiKey = _options.ApiKey == null ? "_" : _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);
@@ -123,21 +164,21 @@ namespace UmlautAdaptarr.Services
{
var headers = new Dictionary<string, string>();
var headerString = Encoding.ASCII.GetString(buffer, 0, length);
var lines = headerString.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries);
var lines = headerString.Split(newLineSeparator, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines.Skip(1)) // Skip the request line
{
var colonIndex = line.IndexOf(':');
if (colonIndex > 0)
{
var key = line.Substring(0, colonIndex).Trim();
var value = line.Substring(colonIndex + 1).Trim();
var key = line[..colonIndex].Trim();
var value = line[(colonIndex + 1)..].Trim();
headers[key] = value;
}
}
return headers;
}
private (string host, int port) ParseTargetInfo(string requestLine)
private static (string host, int port) ParseTargetInfo(string requestLine)
{
var parts = requestLine.Split(' ')[1].Split(':');
return (parts[0], int.Parse(parts[1]));
@@ -150,7 +191,7 @@ namespace UmlautAdaptarr.Services
await Task.WhenAll(clientToTargetTask, targetToClientTask);
}
private async Task RelayStream(NetworkStream input, NetworkStream output)
private static async Task RelayStream(NetworkStream input, NetworkStream output)
{
byte[] buffer = new byte[8192];
int bytesRead;
@@ -163,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,12 +1,11 @@
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Services.Factory;
namespace UmlautAdaptarr.Services
{
public class SearchItemLookupService(CacheService cacheService,
SonarrClient sonarrClient,
ReadarrClient readarrClient,
LidarrClient lidarrClient)
ArrApplicationFactory arrApplicationFactory)
{
public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId)
{
@@ -22,24 +21,41 @@ namespace UmlautAdaptarr.Services
switch (mediaType)
{
case "tv":
if (sonarrClient.SonarrOptions.Enabled)
var sonarrInstances = arrApplicationFactory.SonarrInstances;
if (sonarrInstances.Any())
{
foreach (var sonarrClient in sonarrInstances)
{
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId);
}
}
break;
case "audio":
if (lidarrClient.LidarrOptions.Enabled)
var lidarrInstances = arrApplicationFactory.LidarrInstances;
if (lidarrInstances.Any())
{
foreach (var lidarrClient in lidarrInstances)
{
await lidarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
}
}
break;
case "book":
if (readarrClient.ReadarrOptions.Enabled)
var readarrInstances = arrApplicationFactory.ReadarrInstances;
if (readarrInstances.Any())
{
foreach (var readarrClient in readarrInstances)
{
await readarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
}
}
break;
}
@@ -66,7 +82,9 @@ namespace UmlautAdaptarr.Services
switch (mediaType)
{
case "tv":
if (sonarrClient.SonarrOptions.Enabled)
var sonarrInstances = arrApplicationFactory.SonarrInstances;
foreach (var sonarrClient in sonarrInstances)
{
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title);
}

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

@@ -69,11 +69,11 @@ namespace UmlautAdaptarr.Services
public void FindAndReplaceForBooksAndAudio(SearchItem searchItem, XElement? titleElement, string originalTitle)
{
var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
var (foundMatch, bestStart, bestEndInOriginal) = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
if (authorMatch.foundMatch && titleMatch.foundMatch)
if (authorMatch.foundMatch && foundMatch)
{
int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, titleMatch.bestEndInOriginal);
int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, bestEndInOriginal);
// Check and adjust for immediate following delimiter
char[] delimiters = [' ', '-', '_', '.'];
@@ -103,7 +103,7 @@ namespace UmlautAdaptarr.Services
}
private (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
private static (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
{
bool found = false;
int bestStart = int.MaxValue;
@@ -131,7 +131,7 @@ namespace UmlautAdaptarr.Services
}
// Maps an index from the normalized string back to a corresponding index in the original string
private int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex)
private static int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex)
{
// Count non-special characters up to the given index in the normalized string
int nonSpecialCharCount = 0;
@@ -196,9 +196,9 @@ namespace UmlautAdaptarr.Services
// Workaround for the rare case of e.g. "Frieren: Beyond Journey's End" that also has the alias "Frieren"
if (expectedTitle!.StartsWith(variation, StringComparison.OrdinalIgnoreCase))
{
// See if we already matched the whole title by checking if S01E01 pattern is coming next to avoid false positives
// See if we already matched the whole title by checking if S01E01/S2024E123 pattern is coming next to avoid false positives
// - that won't help with movies but with tv shows
var seasonMatchingPattern = $"^{separator}S\\d{{1,2}}E\\d{{1,2}}";
var seasonMatchingPattern = $"^{separator}S\\d{{1,4}}E\\d{{1,4}}";
if (!Regex.IsMatch(suffix, seasonMatchingPattern))
{
logger.LogWarning($"TitleMatchingService - Didn't rename: '{originalTitle}' because the expected title '{expectedTitle}' starts with the variation '{variation}'");
@@ -209,16 +209,8 @@ namespace UmlautAdaptarr.Services
// Clean up any leading separator from the suffix
suffix = Regex.Replace(suffix, "^ +", "");
// TODO EVALUTE! definitely make this optional - this adds GERMAN to the title is the title is german to make sure it's recognized as german
// can lead to problems with shows such as "dark" that have international dubs
/*
// Check if "german" is not in the original title, ignoring case
if (!Regex.IsMatch(originalTitle, "german", RegexOptions.IgnoreCase))
{
// Insert "GERMAN" after the newTitlePrefix
newTitlePrefix += separator + "GERMAN";
}
*/
// TODO add this when radarr is implemented
// FixBadReleaseNaming
// Construct the new title with the original suffix
var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : suffix.StartsWith(separator) ? suffix : $"{separator}{suffix}");
@@ -233,6 +225,50 @@ namespace UmlautAdaptarr.Services
}
}
private static readonly string[] MissingGermanTagReleaseGroups = ["tvr"];
private static readonly string[] HEVCInsteadOfx265TagReleaseGroups = ["eisbaer"];
private static readonly string[] WrongTagsReleaseGroups = ["eisbaer"];
private static string FixBadReleaseNaming(string title, string seperator, ILogger<TitleMatchingService> logger)
{
var releaseGroup = GetReleaseGroup(title);
if (MissingGermanTagReleaseGroups.Contains(releaseGroup))
{
// Check if "german" is not in the title, ignoring case
if (!Regex.IsMatch(title, "german", RegexOptions.IgnoreCase))
{
logger.LogInformation($"FixBadReleaseNaming - found missing GERMAN tag for {title}");
// TODO not finished
// Insert "GERMAN" after the newTitlePrefix
//newTitlePrefix += separator + "GERMAN";
}
}
if (HEVCInsteadOfx265TagReleaseGroups.Contains(releaseGroup))
{
if (!title.Contains("REMUX", StringComparison.InvariantCultureIgnoreCase))
{
logger.LogInformation($"FixBadReleaseNaming - found HEVC instead of x265 for {title}");
title = title.Replace("HEVC", "x265");
}
}
if (WrongTagsReleaseGroups.Contains(releaseGroup))
{
if (title.Contains($"{seperator}RM{seperator}"))
{
logger.LogInformation($"FixBadReleaseNaming - found bad Tag RM instead of REMASTERED for {title}");
title = title.Replace($"{seperator}RM{seperator}", $"{seperator}REMASTERED{seperator}");
}
}
return "";
}
private static string? GetReleaseGroup(string title)
{
return title.Contains('-') ? title[(title.LastIndexOf('-') + 1)..].Trim() : null;
}
private static string ReplaceSeperatorsWithSpace(string title)
{
// Replace all known separators with space for normalization
@@ -278,7 +314,7 @@ namespace UmlautAdaptarr.Services
{
return "book";
}
else if (category == "3000" || category.StartsWith("Audio"))
else if (category == "3000" || category.StartsWith("Audio", StringComparison.OrdinalIgnoreCase))
{
return "audio";
}

View File

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

View File

@@ -0,0 +1,69 @@
using System.Collections;
using System.Text.RegularExpressions;
using Serilog.Core;
using Serilog.Events;
namespace UmlautAdaptarr.Utilities;
public class ApiKeyMaskingEnricher : ILogEventEnricher
{
private readonly List<string> apiKeys = new();
public ApiKeyMaskingEnricher(string appsetting)
{
ExtractApiKeysFromAppSettings(appsetting);
ExtractApiKeysFromEnvironmentVariables();
apiKeys = new List<string>(apiKeys.Distinct());
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
//if (logEvent.Properties.TryGetValue("apikey", out var value) && value is ScalarValue scalarValue)
//{
var maskedValue = new ScalarValue("**Hidden Api Key**");
foreach (var apikey in apiKeys) logEvent.AddOrUpdateProperty(new LogEventProperty(apikey, maskedValue));
// }
}
/// <summary>
/// Scan all Env Variabels for known Apikeys
/// </summary>
/// <returns>List of all Apikeys</returns>
public List<string> ExtractApiKeysFromEnvironmentVariables()
{
var envVariables = Environment.GetEnvironmentVariables();
foreach (DictionaryEntry envVariable in envVariables)
if (envVariable.Key.ToString()!.Contains("ApiKey"))
apiKeys.Add(envVariable.Value.ToString());
return apiKeys;
}
public List<string> ExtractApiKeysFromAppSettings(string filePath)
{
try
{
if (File.Exists(filePath))
{
var fileContent = File.ReadAllText(filePath);
var pattern = "\"ApiKey\": \"(.*?)\"";
var regex = new Regex(pattern);
var matches = regex.Matches(fileContent);
foreach (Match match in matches) apiKeys.Add(match.Groups[1].Value);
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
return apiKeys;
}
}

View File

@@ -99,7 +99,7 @@ namespace UmlautAdaptarr.Utilities
{
if (removeUmlauts)
{
return NoSpecialCharactersExceptHypenRegex().Replace(text, "");
return NoSpecialCharactersExceptHyphenRegex().Replace(text, "");
}
else
{
@@ -157,9 +157,9 @@ namespace UmlautAdaptarr.Utilities
}
[GeneratedRegex("[^a-zA-Z0-9 -]+", RegexOptions.Compiled)]
private static partial Regex NoSpecialCharactersExceptHypenRegex();
private static partial Regex NoSpecialCharactersExceptHyphenRegex();
[GeneratedRegex("[^a-zA-Z0-9 -öäüßÖÄÜß]+", RegexOptions.Compiled)]
[GeneratedRegex("[^a-zA-Z0-9 öäüßÖÄÜß-]+", RegexOptions.Compiled)]
private static partial Regex NoSpecialCharactersExceptHyphenAndUmlautsRegex();
[GeneratedRegex(@"\s+")]

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace UmlautAdaptarr.Utilities;
public static class Helper
{
public static void ShowLogo()
{
Console.WriteLine(
"\r\n _ _ _ _ ___ _ _ \r\n| | | | | | | | / _ \\ | | | | \r\n| | | |_ __ ___ | | __ _ _ _| |_/ /_\\ \\ __| | __ _ _ __ | |_ __ _ _ __ _ __ \r\n| | | | '_ ` _ \\| |/ _` | | | | __| _ |/ _` |/ _` | '_ \\| __/ _` | '__| '__|\r\n| |_| | | | | | | | (_| | |_| | |_| | | | (_| | (_| | |_) | || (_| | | | | \r\n \\___/|_| |_| |_|_|\\__,_|\\__,_|\\__\\_| |_/\\__,_|\\__,_| .__/ \\__\\__,_|_| |_| \r\n | | \r\n |_| \r\n");
}
public static async Task ShowInformation()
{
Console.WriteLine("--------------------------[IP Leak Test]-----------------------------");
var ipInfo = await GetPublicIpAddressInfoAsync();
if (ipInfo != null)
{
Console.WriteLine($"Your Public IP Address is '{ipInfo.Ip}'");
Console.WriteLine($"Hostname: {ipInfo.Hostname}");
Console.WriteLine($"City: {ipInfo.City}");
Console.WriteLine($"Region: {ipInfo.Region}");
Console.WriteLine($"Country: {ipInfo.Country}");
Console.WriteLine($"Provider: {ipInfo.Org}");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Error: Could not retrieve public IP information.");
Console.ResetColor();
}
Console.WriteLine("--------------------------------------------------------------------");
}
private static async Task<IpInfo?> GetPublicIpAddressInfoAsync()
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(10);
try
{
var response = await client.GetAsync("https://ipinfo.io/json");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<IpInfo>(content);
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,74 @@
using System.Collections;
using System.Collections.ObjectModel;
namespace UmlautAdaptarr.Utilities
{
// License: This code is published under the MIT license.
// Source: https://stackoverflow.com/questions/77559201/
public static class KeyedServiceExtensions
{
public static void AllowResolvingKeyedServicesAsDictionary(
this IServiceCollection sc)
{
// KeyedServiceCache caches all the keys of a given type for a
// specific service type. By making it a singleton we only have
// determine the keys once, which makes resolving the dict very fast.
sc.AddSingleton(typeof(KeyedServiceCache<,>));
// KeyedServiceCache depends on the IServiceCollection to get
// the list of keys. That's why we register that here as well, as it
// is not registered by default in MS.DI.
sc.AddSingleton(sc);
// Last we make the registration for the dictionary itself, which maps
// to our custom type below. This registration must be transient, as
// the containing services could have any lifetime and this registration
// should by itself not cause Captive Dependencies.
sc.AddTransient(typeof(IDictionary<,>), typeof(KeyedServiceDictionary<,>));
// For completeness, let's also allow IReadOnlyDictionary to be resolved.
sc.AddTransient(
typeof(IReadOnlyDictionary<,>), typeof(KeyedServiceDictionary<,>));
}
// We inherit from ReadOnlyDictionary, to disallow consumers from changing
// the wrapped dependencies while reusing all its functionality. This way
// we don't have to implement IDictionary<T,V> ourselves; too much work.
private sealed class KeyedServiceDictionary<TKey, TService>(
KeyedServiceCache<TKey, TService> keys, IServiceProvider provider)
: ReadOnlyDictionary<TKey, TService>(Create(keys, provider))
where TKey : notnull
where TService : notnull
{
private static Dictionary<TKey, TService> Create(
KeyedServiceCache<TKey, TService> keys, IServiceProvider provider)
{
var dict = new Dictionary<TKey, TService>(capacity: keys.Keys.Length);
foreach (TKey key in keys.Keys)
{
dict[key] = provider.GetRequiredKeyedService<TService>(key);
}
return dict;
}
}
private sealed class KeyedServiceCache<TKey, TService>(IServiceCollection sc)
where TKey : notnull
where TService : notnull
{
// Once this class is resolved, all registrations are guaranteed to be
// made, so we can, at that point, safely iterate the collection to get
// the keys for the service type.
public TKey[] Keys { get; } = (
from service in sc
where service.ServiceKey != null
where service.ServiceKey!.GetType() == typeof(TKey)
where service.ServiceType == typeof(TService)
select (TKey)service.ServiceKey!)
.ToArray();
}
}
}

View File

@@ -1,53 +0,0 @@
using System;
using System.Net;
using UmlautAdaptarr.Options;
namespace UmlautAdaptarr.Utilities
{
/// <summary>
/// Extension methods for configuring proxies.
/// </summary>
public static class ProxyExtension
{
/// <summary>
/// Logger instance for logging proxy configurations.
/// </summary>
private static ILogger Logger = GlobalStaticLogger.Logger;
/// <summary>
/// Configures the proxy settings for the provided HttpClientHandler instance.
/// </summary>
/// <param name="handler">The HttpClientHandler instance to configure.</param>
/// <param name="proxyOptions">ProxyOptions options to be used for configuration.</param>
/// <returns>The configured HttpClientHandler instance.</returns>
public static HttpClientHandler ConfigureProxy(this HttpClientHandler handler, ProxyOptions? proxyOptions)
{
try
{
if (proxyOptions != null && proxyOptions.Enabled)
{
Logger.LogInformation("Use Proxy {0}", proxyOptions.Address);
handler.UseProxy = true;
handler.Proxy = new WebProxy(proxyOptions.Address, proxyOptions.BypassOnLocal);
if (!string.IsNullOrEmpty(proxyOptions.Username) && !string.IsNullOrEmpty(proxyOptions.Password))
{
Logger.LogInformation("Use Proxy Credentials from User {0}", proxyOptions.Username);
handler.DefaultProxyCredentials =
new NetworkCredential(proxyOptions.Username, proxyOptions.Password);
}
}
else
{
Logger.LogDebug("No proxy was set");
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error occurred while configuring proxy, no Proxy will be used!");
}
return handler;
}
}
}

View File

@@ -1,16 +1,120 @@
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Options.ArrOptions;
using FluentValidation;
using System.Linq.Expressions;
using UmlautAdaptarr.Interfaces;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Validator;
namespace UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Utilities
{
/// <summary>
/// Extension methods for configuring services related to ARR Applications
/// </summary>
public static class ServicesExtensions
{
/// <summary>
/// Logger instance for logging proxy configurations.
/// </summary>
private static ILogger Logger = GlobalStaticLogger.Logger;
/// <summary>
/// Adds a service with specified options and service to the service collection.
/// </summary>
/// <typeparam name="TOptions">The options type for the service.</typeparam>
/// <typeparam name="TService">The service type for the service.</typeparam>
/// <typeparam name="TInterface">The Interface of the service type</typeparam>
/// <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 async Task<WebApplicationBuilder> AddServicesWithOptions<TOptions, TService, TInterface>(
this WebApplicationBuilder builder, string sectionName)
where TOptions : class, new()
where TService : class, TInterface
where TInterface : class
{
try
{
if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null.");
var singleInstance = builder.Configuration.GetSection(sectionName).Get<TOptions>();
var singleHost = (string?)typeof(TOptions).GetProperty("Host")?.GetValue(singleInstance, null);
// If we have no Single Instance, we try to parse for an Array
var optionsArray = singleHost == null
? builder.Configuration.GetSection(sectionName).Get<TOptions[]>()
:
[
singleInstance
];
if (optionsArray == null || !optionsArray.Any())
throw new InvalidOperationException(
$"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable.");
foreach (var option in optionsArray)
{
GlobalInstanceOptionsValidator validator = new();
var results = await validator.ValidateAsync(option as GlobalInstanceOptions);
if (!results.IsValid)
{
foreach (var failure in results.Errors)
{
Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}"));
}
throw new Exception("Please fix cour environment variables and then Start UmlautAdaptarr again");
}
var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false);
// We only want to create instances that are enabled in the Configs
if (instanceState)
{
// User can give the Instance a readable Name otherwise we use the Host Property
var instanceName = (string)(typeof(TOptions).GetProperty("Name")?.GetValue(option, null) ??
(string)typeof(TOptions).GetProperty("Host")?.GetValue(option, null)!);
// Dark Magic , we don't know the Property's of TOptions , and we won't cast them for each Options
// Todo eventuell schönere Lösung finden
var paraexpression = Expression.Parameter(Type.GetType(option.GetType().FullName), "x");
foreach (var prop in option.GetType().GetProperties())
{
var val = Expression.Constant(prop.GetValue(option));
var memberexpression = Expression.PropertyOrField(paraexpression, prop.Name);
if (prop.PropertyType == typeof(int) || prop.PropertyType == typeof(string) || prop.PropertyType == typeof(bool))
{
var assign = Expression.Assign(memberexpression, Expression.Convert(val, prop.PropertyType));
var exp = Expression.Lambda<Action<TOptions>>(assign, paraexpression);
builder.Services.Configure(instanceName, exp.Compile());
}
else
{
Logger.LogWarning(prop.PropertyType + "No Support");
}
}
builder.Services.AddKeyedSingleton<TInterface, TService>(instanceName);
}
}
return builder;
}
catch (Exception ex)
{
Console.WriteLine($"Error in AddServicesWithOptions: {ex.Message}");
throw;
}
}
/// <summary>
/// Adds a service with specified options and service to the service collection.
/// </summary>
@@ -19,23 +123,18 @@ namespace UmlautAdaptarr.Utilities
/// <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 AddServiceWithOptions<TOptions, TService>(this WebApplicationBuilder builder, string sectionName)
private static WebApplicationBuilder AddServiceWithOptions<TOptions, TService>(this WebApplicationBuilder builder,
string sectionName)
where TOptions : class
where TService : class
{
if (builder.Services == null)
{
throw new ArgumentNullException(nameof(builder), "Service collection is null.");
}
var options = builder.Configuration.GetSection(sectionName).Get<TOptions>();
if (options == null)
{
throw new InvalidOperationException($"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable.");
}
if (builder.Services == null) throw new ArgumentNullException(nameof(builder), "Service collection is null.");
var options = builder.Configuration.GetSection(sectionName).Get<TOptions>() ?? throw new InvalidOperationException(
$"{typeof(TService).Name} options could not be loaded from Configuration or ENV Variable.");
builder.Services.Configure<TOptions>(builder.Configuration.GetSection(sectionName));
builder.Services.AddSingleton<TService>();
return builder;
}
@@ -44,9 +143,10 @@ namespace UmlautAdaptarr.Utilities
/// </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)
{
return builder.AddServiceWithOptions<SonarrInstanceOptions, SonarrClient>("Sonarr");
// builder.Serviceses.AddSingleton<IOptionsMonitoSonarrInstanceOptionsns>, OptionsMonitoSonarrInstanceOptionsns>>();
return builder.AddServicesWithOptions<SonarrInstanceOptions, SonarrClient, IArrApplication>("Sonarr");
}
/// <summary>
@@ -54,9 +154,9 @@ namespace UmlautAdaptarr.Utilities
/// </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.AddServiceWithOptions<LidarrInstanceOptions, LidarrClient>("Lidarr");
return builder.AddServicesWithOptions<LidarrInstanceOptions, LidarrClient, IArrApplication>("Lidarr");
}
/// <summary>
@@ -64,9 +164,9 @@ namespace UmlautAdaptarr.Utilities
/// </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.AddServiceWithOptions<ReadarrInstanceOptions, ReadarrClient>("Readarr");
return builder.AddServicesWithOptions<ReadarrInstanceOptions, ReadarrClient, IArrApplication>("Readarr");
}
/// <summary>
@@ -89,4 +189,3 @@ namespace UmlautAdaptarr.Utilities
return builder.AddServiceWithOptions<GlobalOptions, ProxyRequestService>("Settings");
}
}
}

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

@@ -0,0 +1,71 @@
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();
When(x => x.Enabled, () =>
{
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.");
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");
});
}
private bool BeAValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
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
{
using var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
reachable = true;
break;
}
else
{
Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}.");
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
// Wait for 15 seconds for next try
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,22 +20,41 @@
// 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
},
"Sonarr": {
"Sonarr": [
{
// Docker Environment Variables:
// - Sonarr__Enabled: true (set to false to disable)
// - Sonarr__Host: your_sonarr_host_url
// - Sonarr__ApiKey: your_sonarr_api_key
// - Sonarr__0__Enabled: true (set to false to disable)
// - Sonarr__0__Name: Name of the Instance (Optional)
// - Sonarr__0__Host: your_sonarr_host_url
// - Sonarr__0__ApiKey: your_sonarr_api_key
"Enabled": false,
"Name": "Sonarr",
"Host": "your_sonarr_host_url",
"ApiKey": "your_sonarr_api_key"
},
"Lidarr": {
{
// Docker Environment Variables:
// - Sonarr__1__Enabled: true (set to false to disable)
// - Sonarr__1__Name: Name of the Instance (Optional)
// - Sonarr__1__Host: your_sonarr_host_url
// - Sonarr__1__ApiKey: your_sonarr_api_key
"Enabled": false,
"Name": "Sonarr 4k",
"Host": "your_other_sonarr_host_url",
"ApiKey": "your_other_sonarr_api_key"
}
],
"Lidarr":
// Docker Environment Variables:
// - Lidarr__Enabled: true (set to false to disable)
// - Lidarr__Host: your_lidarr_host_url
// - Lidarr__ApiKey: your_lidarr_api_key
{
"Enabled": false,
"Host": "your_lidarr_host_url",
"ApiKey": "your_lidarr_api_key"
@@ -49,18 +68,9 @@
"Host": "your_readarr_host_url",
"ApiKey": "your_readarr_api_key"
},
"IpLeakTest": {
// Docker Environment Variables:
// - Proxy__Enabled: true (set to false to disable)
// - Proxy__Address: http://yourproxyaddress:port
// - Proxy__Username: your_proxy_username
// - Proxy__Password: your_proxy_password
// - Proxy__BypassOnLocal: true (set to false to not bypass local IP addresses)
"Proxy": {
"Enabled": false,
"Address": "http://yourproxyaddress:port",
"Username": "your_proxy_username",
"Password": "your_proxy_password",
"BypassOnLocal": true
// - IpLeakTest__Enabled: false (set to true to enable)
"Enabled": false
}
}

4
build_linux.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
dotnet publish -c Release -r linux-x64 --self-contained
'dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -p:IncludeAllContentForSelfExtract=true
pause

View File

@@ -6,6 +6,9 @@ services:
build: https://github.com/PCJones/UmlautAdaptarr.git#master
image: umlautadaptarr
restart: unless-stopped
ports:
- "5005:5005" # can be removed if you use the recommended prowlarr+proxy configuration
- "5006:5006" # can be removed if you use the alternative, non-proxy configuration
environment:
- TZ=Europe/Berlin
- SONARR__ENABLED=false
@@ -20,8 +23,20 @@ services:
- LIDARR__ENABLED=false
- LIDARR__HOST=http://localhost:8686
- LIDARR__APIKEY=APIKEY
#- Proxy__Enabled: false
#- Proxy__Address: http://yourproxyaddress:port
#- Proxy__Username: your_proxy_username
#- Proxy__Password: your_proxy_password
#- Proxy__BypassOnLocal: true (set to false to not bypass local IP addresses)
### example for multiple instances of same type
#- SONARR__0__NAME=NAME 1 (optional)
#- SONARR__0__ENABLED=false
#- SONARR__0__HOST=http://localhost:8989
#- SONARR__0__APIKEY=APIKEY
#- SONARR__1__NAME=NAME 2 (optional)
#- 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