70 Commits

Author SHA1 Message Date
pcjones
d70ae41951 v0.7.5 (#82)
* Add newzbay categories; add title lookup API

* Update dependencies

* Update docker compose

* Fix title lookup colon workaround

* Remove rid and tmdbid from text search queries
2025-11-18 11:01:15 +01:00
pcjones
b94c6bc6ad Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2025-11-16 19:37:50 +01:00
pcjones
feae0ca309 Fix releases with ":" not working in title lookup API 2025-11-16 19:35:42 +01:00
pcjones
b828b64a22 Update run_on_seedbox.sh 2025-07-24 16:54:20 +02:00
pcjones
c48db39d04 v0.7.3 (#74)
* Add newzbay categories; add title lookup API

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

* Fix reachable and IP leak test (#44)

* Fix reachable check

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

* Add option to disable IP leak test

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

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

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

* Add option to disable IP leak test

---------



* Add IpLeakTest environment variable to docker compose

---------



* Create Dockerfile.arm64

---------



* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

* Fix too many Unauthorized access attempt warnings

---------

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

* Fix reachable and IP leak test (#44)

* Fix reachable check

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

* Add option to disable IP leak test

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

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

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

* Add option to disable IP leak test

---------

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

* Add IpLeakTest environment variable to docker compose

---------

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

* Create Dockerfile.arm64

---------

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

* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

* Fix too many Unauthorized access attempt warnings

---------

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

* Fix reachable and IP leak test (#44)

* Fix reachable check

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

* Add option to disable IP leak test

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

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

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

* Add option to disable IP leak test

---------

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

* Add IpLeakTest environment variable to docker compose

---------

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

* Create Dockerfile.arm64

---------

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

* Add configurable cache duration

* Make proxy port configurable

* Make proxy port configurable

* Add API Key auth

* Add default settings to appsettings

---------

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

* Fix reachable check

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

* Add option to disable IP leak test

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

This reverts commit 3f5d7bbef3.

* Release 0.6.1 (#48)

* Fix typo

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

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

* Add option to disable IP leak test

---------

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

* Add IpLeakTest environment variable to docker compose

---------

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

* Create Dockerfile.arm64

---------

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

* Fix typos

* Fix typos

* Fix typo

* Clarify error message

* Fix reachable and ipleak test (#47)

* Fix reachable check

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

* Add option to disable IP leak test

---------

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

* Add IpLeakTest environment variable to docker compose

---------

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

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

* Add option to disable IP leak test

---------

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

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

* Add option to disable IP leak test
2024-10-21 14:26:35 +02:00
pcjones
e95d18ed91 Clarify error message 2024-10-21 14:25:03 +02:00
pcjones
95f5054829 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-10-12 14:24:43 +02:00
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
22 changed files with 549 additions and 151 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,15 +1,14 @@
# 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.
@@ -24,37 +23,38 @@ Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAda
| Feature | Status |
|-------------------------------------------------------------------|---------------|
| Prowlarr & NZB Hydra Support | ✓|
| Sonarr Support | ✓ |
| Lidarr Support | ✓|
| Readarr Support | ✓ |
| 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 | ✓ |
| Usenet (newznab) Support |✓|
| Torrent (torznab) Support |✓|
| Support von mehreren *arr-Instanzen des gleichen Typs (z.B. 2x Sonarr) |
| Releases mit mit schlechtem Naming werden korrekt umbenannt (optional) | in Arbeit |
| Prowlarr & NZB Hydra Support |✓ |
| Sonarr Support |✓ |
| Lidarr Support |✓ |
| Readarr Support |✓ |
| 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-Zugriff |✓ |
| Usenet (newznab) Support |✓ |
| Torrent (torznab) Support |✓ |
| 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 |
| Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant |
| Unterstützung weiterer Sprachen neben Deutsch | Geplant |
| Wünsche? | Vorschläge? |
## Installation
Momentan ist Docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. Eine Unraid-App gibt es auch, einfach nach `umlautadaptarr` suchen.
- [Docker](https://hub.docker.com/r/pcjones/umlautadaptarr)
- Unraid: nach `umlautadaptarr` suchen
- [Proxmox LXC (unofficial)](https://community-scripts.github.io/ProxmoxVE/scripts?id=umlautadaptarr) - appsettings.json muss nach Installation konfiguriert werden
- [Seedbox/Binary](https://github.com/PCJones/UmlautAdaptarr/blob/master/run_on_seedbox.sh)
[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)
@@ -63,19 +63,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:
@@ -120,11 +121,13 @@ Sonarr erwartet immer den Englischen Namen, der hier natürlich nicht gegeben is
## Kontakt & Support
- Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst.
- [Telegram](https://t.me/pc_jones)
- Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM)
- [UsenetDE Discord Server](https://discord.gg/src6zcH4rr) -> #umlautadaptarr
## Spenden
Über eine Spende freue ich mich natürlich immer :D
PayPal: https://paypal.me/pcjones1
<a href="https://www.buymeacoffee.com/pcjones" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="60px" width="217px" ></a>
<a href="https://coindrop.to/pcjones" target="_blank"><img src="https://coindrop.to/embed-button.png" style="border-radius: 10px; height: 57px !important;width: 229px !important;" alt="Coindrop.to me"></img></a>
Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!
@@ -132,3 +135,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 (!string.IsNullOrEmpty(apikey) && !apiKey.Equals(apiKey))
{
_logger.LogWarning("Invalid or missing API key for request.");
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
if (!domain.StartsWith("localhost") && !UrlUtilities.IsValidDomain(domain))
{
return NotFound($"{domain} is not a valid URL.");
}

View File

@@ -1,30 +1,36 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Controllers
{
public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService) : ControllerBase
public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, IOptions<GlobalOptions> options, ILogger<SearchControllerBase> logger) : ControllerBase
{
// TODO evaluate if this should be set to true by default
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true;
private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options,
protected async Task<IActionResult?> BaseSearch(string apiKey,
string domain,
IDictionary<string, string> queryParameters,
SearchItem? searchItem = null)
{
try
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
if (!UrlUtilities.IsValidDomain(domain))
{
return NotFound($"{domain} is not a valid URL.");
}
ContentResult? initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult;
if (initialSearchResult == null)
if (await PerformSingleSearchRequest(domain, queryParameters) is not ContentResult initialSearchResult)
{
return null;
}
@@ -50,6 +56,8 @@ namespace UmlautAdaptarr.Controllers
queryParameters.Remove("tvdbid");
queryParameters.Remove("tvmazeid");
queryParameters.Remove("imdbid");
queryParameters.Remove("rid");
queryParameters.Remove("tmdbid");
var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations);
@@ -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 (!string.IsNullOrEmpty(options.Value.ApiKey) && !apiKey.Equals(options.Value.ApiKey))
{
logger.LogWarning("Invalid or missing API key for request.");
return false;
}
return true;
}
}
public class SearchController(ProxyRequestService proxyRequestService,
TitleMatchingService titleMatchingService,
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyRequestService, titleMatchingService)
SearchItemLookupService searchItemLookupService,
IOptions<GlobalOptions> options,
ILogger<SearchControllerBase> logger) : SearchControllerBase(proxyRequestService, titleMatchingService, options, logger)
{
public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"];
public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"];
[HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> MovieSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
q => q.Key,
q => string.Join(",", q.Value));
return await BaseSearch(options, domain, queryParameters);
return await BaseSearch(apiKey, domain, queryParameters);
}
[HttpGet]
public async Task<IActionResult> GenericSearch([FromRoute] string options, [FromRoute] string domain)
public async Task<IActionResult> GenericSearch([FromRoute] string apiKey, [FromRoute] string domain)
{
if (!AssureApiKey(apiKey))
{
return Unauthorized("Unauthorized: Invalid or missing API key.");
}
var queryParameters = HttpContext.Request.Query.ToDictionary(
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,32 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Services;
namespace UmlautAdaptarr.Controllers
{
[ApiController]
[Route("titlelookup/")]
public class TitleLookupController(CacheService cacheService, IOptions<GlobalOptions> options) : ControllerBase
{
GlobalOptions _options = options.Value;
[HttpGet]
public IActionResult GetOriginalTitle([FromQuery] string changedTitle)
{
if (!_options.EnableChangedTitleCache)
{
return StatusCode(501, "Set SETTINGS__EnableChangedTitleCache to true to use this endpoint.");
}
if (string.IsNullOrWhiteSpace(changedTitle))
return BadRequest("changedTitle is required.");
var originalTitle = cacheService.GetOriginalTitleFromRenamed(changedTitle);
return originalTitle != null
? Ok(new { changedTitle, originalTitle })
: NotFound($"Original title not found for '{changedTitle}'.");
}
}
}

View File

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

View File

@@ -1,7 +1,6 @@
using System.Net;
using Serilog;
using Serilog.Filters;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Routing;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Services.Factory;
@@ -11,22 +10,16 @@ internal class Program
{
private static void Main(string[] args)
{
Helper.ShowLogo();
Helper.ShowInformation();
MainAsync(args).Wait();
}
private static async Task MainAsync(string[] args)
{
// TODO:
// add option to sort by nzb age
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
// TODO workaround to not log api keys
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.WriteTo.Console(outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.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();
ConfigureLogger(configuration);
builder.Services.AddSerilog();
@@ -53,9 +46,9 @@ internal class Program
builder.AddTitleLookupService();
builder.Services.AddSingleton<SearchItemLookupService>();
builder.Services.AddSingleton<TitleMatchingService>();
builder.AddSonarrSupport();
builder.AddLidarrSupport();
builder.AddReadarrSupport();
await builder.AddSonarrSupport();
await builder.AddLidarrSupport();
await builder.AddReadarrSupport();
builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyRequestService>();
builder.Services.AddSingleton<ArrApplicationFactory>();
@@ -64,39 +57,66 @@ internal class Program
var app = builder.Build();
Helper.ShowLogo();
if (app.Configuration.GetValue<bool>("IpLeakTest:Enabled"))
{
await Helper.ShowInformation();
}
GlobalStaticLogger.Initialize(app.Services.GetService<ILoggerFactory>()!);
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllerRoute("caps",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Caps", action = "Caps" },
new { t = new TRouteConstraint("caps") });
app.MapControllerRoute("movie-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MovieSearch" },
new { t = new TRouteConstraint("movie") });
app.MapControllerRoute("tv-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "TVSearch" },
new { t = new TRouteConstraint("tvsearch") });
app.MapControllerRoute("music-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "MusicSearch" },
new { t = new TRouteConstraint("music") });
app.MapControllerRoute("book-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "BookSearch" },
new { t = new TRouteConstraint("book") });
app.MapControllerRoute("generic-search",
"{options}/{*domain}",
"{apiKey}/{*domain}",
new { controller = "Search", action = "GenericSearch" },
new { t = new TRouteConstraint("search") });
app.Run();
}
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

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

View File

@@ -1,7 +1,4 @@
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Caching.Memory;
using System.Reflection.Metadata.Ecma335;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Utilities;
@@ -13,6 +10,8 @@ namespace UmlautAdaptarr.Services
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> BookVariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
private const string TitleRenamePrefix = "title_rename_";
private static readonly TimeSpan TitleRenameCacheDuration = TimeSpan.FromHours(12);
public void CacheSearchItem(SearchItem item)
{
@@ -196,8 +195,26 @@ namespace UmlautAdaptarr.Services
return null;
}
public void CacheTitleRename(string changedTitle, string originalTitle)
{
if (string.IsNullOrWhiteSpace(changedTitle) || string.IsNullOrWhiteSpace(originalTitle))
return;
[GeneratedRegex("\\s")]
private static partial Regex WhiteSpaceRegex();
var key = $"{TitleRenamePrefix}{changedTitle.Trim().ToLowerInvariant()}";
cache.Set(key, originalTitle, TitleRenameCacheDuration);
// If title contains ":" also add it as "-" for arr/sabnzbd compatibility
if (changedTitle.Contains(':'))
{
var altKey = $"{TitleRenamePrefix}{changedTitle.Replace(':', '-').Trim().ToLowerInvariant()}";
cache.Set(altKey, originalTitle, TitleRenameCacheDuration);
}
}
public string? GetOriginalTitleFromRenamed(string changedTitle)
{
var key = $"{TitleRenamePrefix}{changedTitle.Trim().ToLowerInvariant()}";
return cache.TryGetValue(key, out string? originalTitle) ? originalTitle : null;
}
}
}

View File

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

View File

@@ -1,6 +1,8 @@
using System.Net;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UmlautAdaptarr.Options;
namespace UmlautAdaptarr.Services
{
@@ -8,15 +10,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 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");
}
@@ -37,6 +42,24 @@ namespace UmlautAdaptarr.Services
var bytesRead = await clientStream.ReadAsync(buffer);
var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead);
if (!string.IsNullOrEmpty(_options.ApiKey))
{
var headers = ParseHeaders(buffer, bytesRead);
if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) ||
!ValidateApiKey(proxyAuthorizationHeader))
{
var isFirstRequest = !headers.ContainsKey("Proxy-Authorization");
if (!isFirstRequest)
{
_logger.LogWarning("Unauthorized access attempt.");
}
await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n"));
clientSocket.Close();
return;
}
}
if (requestString.StartsWith("CONNECT"))
{
// Handle HTTPS CONNECT request
@@ -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 = string.IsNullOrEmpty(_options.ApiKey) ? "_" : _options.ApiKey;
var modifiedUri = $"http://localhost:{port}/{apiKey}/{uri.Host}{uri.PathAndQuery}";
using var client = _clientFactory.CreateClient();
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri);
httpRequestMessage.Headers.Add("User-Agent", userAgent);
@@ -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,6 +1,7 @@
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Utilities;
@@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Services
lastRequestTime = DateTime.Now;
}
// TODO add cache, TODO add bulk request
// TODO add caching
public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId)
{
try
@@ -68,6 +69,68 @@ namespace UmlautAdaptarr.Services
return (null, null);
}
public async Task<Dictionary<string, (string? germanTitle, string[]? aliases)>> FetchGermanTitlesAndAliasesByExternalIdBulkAsync(IEnumerable<string> tvdbIds)
{
try
{
await EnsureMinimumDelayAsync();
var httpClient = clientFactory.CreateClient();
var bulkApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?bulk=true";
logger.LogInformation($"TitleApiService POST {UrlUtilities.RedactApiKey(bulkApiUrl)}");
// Prepare POST request payload
var payload = new { tvdbIds = tvdbIds.ToArray() };
var jsonPayload = JsonConvert.SerializeObject(payload);
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
// Send POST request
var response = await httpClient.PostAsync(bulkApiUrl, content);
if (!response.IsSuccessStatusCode)
{
logger.LogError($"Failed to fetch German titles via bulk API. Status Code: {response.StatusCode}");
return [];
}
var responseContent = await response.Content.ReadAsStringAsync();
var bulkApiResponseData = JsonConvert.DeserializeObject<dynamic>(responseContent);
if (bulkApiResponseData == null || bulkApiResponseData.status != "success")
{
logger.LogError($"Parsing UmlautAdaptarr Bulk API response resulted in null or an error status.");
return [];
}
// Process response data
var results = new Dictionary<string, (string? germanTitle, string[]? aliases)>();
foreach (var entry in bulkApiResponseData.data)
{
string tvdbId = entry.tvdbId;
string? germanTitle = entry.germanTitle;
string[]? aliases = null;
if (entry.aliases != null)
{
JArray aliasesArray = JArray.FromObject(entry.aliases);
aliases = aliasesArray.Children<JObject>()
.Select(alias => alias["name"].ToString())
.ToArray();
}
results[tvdbId] = (germanTitle, aliases);
}
logger.LogInformation($"Successfully fetched German titles for {results.Count} TVDB IDs via bulk API.");
return results;
}
catch (Exception ex)
{
logger.LogError($"Error fetching German titles in bulk: {ex.Message}");
return new Dictionary<string, (string? germanTitle, string[]? aliases)>();
}
}
public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title)
{
try

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
using System.Net;
using FluentValidation;
using FluentValidation;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
namespace UmlautAdaptarr.Validator;
public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOptions>
{
private readonly static HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(3)
};
public GlobalInstanceOptionsValidator()
{
RuleFor(x => x.Enabled).NotNull();
@@ -14,12 +18,14 @@ public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOp
{
RuleFor(x => x.Host)
.NotEmpty().WithMessage("Host is required when Enabled is true.")
.Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.")
.Must(BeReachable)
.WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings");
.Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.");
RuleFor(x => x.ApiKey)
.NotEmpty().WithMessage("ApiKey is required when Enabled is true.");
RuleFor(x => x)
.MustAsync(BeReachable)
.WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings");
});
}
@@ -29,33 +35,37 @@ public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOp
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
private static bool BeReachable(string url)
private static async Task<bool> BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken)
{
var endTime = DateTime.Now.AddMinutes(3);
var reachable = false;
var url = $"{opts.Host}/api?apikey={opts.ApiKey}";
while (DateTime.Now < endTime)
{
try
{
var request = WebRequest.Create(url);
request.Timeout = 3000;
using var response = (HttpWebResponse)request.GetResponse();
reachable = response.StatusCode == HttpStatusCode.OK;
if (reachable)
using var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
reachable = true;
break;
}
catch
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 \"{url}\" is not reachable. Next attempt in 15 seconds...");
Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds...");
Thread.Sleep(15000);
}
return reachable;
}
}

View File

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

View File

@@ -23,3 +23,20 @@ services:
- LIDARR__ENABLED=false
- LIDARR__HOST=http://localhost:8686
- LIDARR__APIKEY=APIKEY
### 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))
#- SETTINGS__EnableChangedTitleCache=false # Enables the changed title API under /titlelookup?changedTitle=$title - enable if you are using crowdnfo.net post processing script.
#- 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
#- IpLeakTest__Enabled=false

53
run_on_seedbox.sh Normal file
View File

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