77 Commits

Author SHA1 Message Date
pcjones
79ad4f9370 Merge branch 'master' into develop 2026-02-01 17:49:30 +01:00
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
2650d32ae2 Remove rid and tmdbid from text search queries 2025-11-18 10:56:05 +01:00
PCJones
5f87a596fb Fix title lookup colon workaround 2025-11-18 10:55:51 +01:00
PCJones
c2b200f3be Update docker compose 2025-11-18 10:55:25 +01:00
PCJones
6ef8cc3cd1 Merge branch 'master' into develop
Resolved conflict in TitleLookupController.cs by keeping the master version with colon handling fix.
This brings develop up to date with master (v0.7.4), including the November bug fixes.
2025-11-18 10:49:49 +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
02cf2854e1 Update dependencies 2025-07-22 16:32:31 +02:00
PCJones
22ecdaa6cd Add newzbay categories; add title lookup API 2025-07-22 16:30:30 +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