104 Commits

Author SHA1 Message Date
Jonas F
d9087e2fe5 Remove proxy environment variables from compose 2024-09-06 20:46:50 +02:00
pcjones
bdd77e11f8 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-09-05 14:24:48 +02:00
pcjones
54fe1c0f89 Remove proxy support 2024-09-05 14:24:42 +02:00
Jonas F
706199074d Update README.md 2024-09-04 20:04:42 +02:00
pcjones
b8575831bd Fix appsettings.json example 2024-09-04 19:40:02 +02:00
pcjones
370e3ca06b Fix warnings 2024-09-04 19:39:15 +02:00
pcjones
238bd9cc60 Code cleanup 2024-09-04 19:30:31 +02:00
Jonas F
4db26e374f Update README.md 2024-09-04 19:17:13 +02:00
Jonas F
ce74044b9b Update README.md 2024-09-04 19:16:37 +02:00
Jonas F
202c09c739 Merge pull request #32 from PCJones/master
Update README.md
2024-09-04 19:16:04 +02:00
Jonas F
c5440fd6c5 Merge pull request #23 from xpsony/multiInstance_serilog
Multi *Arr Instance Support , Add Serilog for better logging, Add Fluent Validator
2024-09-04 19:14:53 +02:00
pcjones
f886b17164 Add FixBadReleaseNaming base concept 2024-09-04 19:00:58 +02:00
Jonas F
40f2131196 Update README.md 2024-09-04 18:31:03 +02:00
pcjones
fd6a8581d8 Also search for "GERMAN" if title ends with "Germany" and also match for title without "Germany" 2024-09-04 18:06:51 +02:00
Felix Glang
74104c300e Create IpInfo.cs
Move To Model
2024-06-09 13:40:47 +02:00
Felix Glang
42554d255e Add IP Infos in Startup
Now User can simply see , if his VPN is working correctly
2024-06-09 13:38:06 +02:00
Felix Glang
c7b92974f4 Update GlobalInstanceOptionsValidator.cs
Add Max Timeout
2024-06-09 12:34:55 +02:00
Felix Glang
265c098630 Fix BeReachable 2024-06-09 12:27:26 +02:00
xpsony
d892f9014e Merge branch 'PCJones:master' into multiInstance_serilog 2024-05-23 09:49:35 +02:00
pcjones
759e276311 Merge branch 'develop' of https://github.com/PCJones/UmlautAdaptarr into develop 2024-05-15 20:40:22 +02:00
pcjones
b44c294782 Fix NoSpecialCharactersExceptHyphenAndUmlautsRegex not matching any special characters 2024-05-15 20:40:18 +02:00
Jonas F
26b030808f Merge pull request #25 from PCJones/PCJones-patch-1
Fix readme link
2024-05-12 13:34:08 +02:00
Jonas F
b5fa4308ea Merge pull request #24 from PCJones/PCJones-patch-1
Fix readme link
2024-05-12 13:33:42 +02:00
Jonas F
dea00feaee Update README.md 2024-05-12 13:33:04 +02:00
Felix Glang
ef7182888b Update GlobalInstanceOptionsValidator.cs
Cleanup Code
2024-04-29 20:35:18 +02:00
Felix Glang
5931fd6a8a Fix Bug
If UmlautAdaparr was started before the *Arr. The BeReachable test failed, although the config was correct. Now it is tested every 15 seconds for 3 minutes whether the corresponding application can be reached. Before the test fails
2024-04-29 20:21:46 +02:00
Felix Glang
c788e0ed76 Fix Log 2024-04-28 13:21:48 +02:00
Felix Glang
0bb480b1d0 Add Config Validator + Bug Fixing 2024-04-28 12:59:44 +02:00
Felix Glang
f73b3b5578 Fix in IOptions Copy Section 2024-04-27 21:53:12 +02:00
Felix Glang
e6173ae683 Add Example for Name 2024-04-27 21:29:23 +02:00
Felix Glang
52acb5ff6e Fix named IOption Bug
Fix  named IOption Bug
2024-04-27 21:27:04 +02:00
xpsony
1a32cc325c Merge branch 'PCJones:master' into multiInstance_serilog 2024-04-27 18:50:41 +02:00
Felix Glang
f06a866a2f Add Multi Instance Support , Serilog , little Hotfixes 2024-04-27 18:48:43 +02:00
Jonas F
90c849de52 Merge pull request #20 from PCJones/develop
Add port mapping
2024-04-25 15:30:14 +02:00
Jonas F
176b0a74a6 Update port mapping comment 2024-04-24 15:24:14 +02:00
Jonas F
ffcc8fddcd Add port mapping 2024-04-24 15:21:12 +02:00
Jonas F
81dae8c237 Merge pull request #19 from PCJones/develop
v0.5 release
2024-04-23 22:01:41 +02:00
Jonas F
aa70c91a87 Update README.md 2024-04-23 21:38:13 +02:00
Jonas F
3d2ec82e0f Merge pull request #18 from Br33ce/patch-1
Update Readme
2024-04-23 17:16:00 +02:00
Br33ce
9c9c1583f7 Update README.md 2024-04-23 17:12:14 +02:00
Br33ce
f9a02ae487 Update README.md 2024-04-23 14:42:43 +02:00
Jonas F
0cc0ca98e1 Update README.md 2024-04-22 14:33:50 +02:00
pcjones
a4abb31ea1 Check for known hosts before logging https warning 2024-04-15 19:25:53 +02:00
pcjones
530cbed2d3 Update docker compose 2024-04-15 04:25:21 +02:00
pcjones
94e62cf4dd Forward user-agent when using http proxy 2024-04-15 03:42:20 +02:00
pcjones
c4069e0732 Change warning text 2024-04-15 03:21:51 +02:00
pcjones
6f743eca01 Revert "Add TODO"
This reverts commit 45bc7baa4a.
2024-04-15 03:20:36 +02:00
pcjones
45bc7baa4a Add TODO 2024-04-15 03:17:14 +02:00
pcjones
43717d5fc4 HttpProxyService: Forward https, modify http 2024-04-15 03:05:09 +02:00
pcjones
08e13db32d Revert "Temporarily remove API key redaction"
This reverts commit f3684d24d3.
2024-04-15 00:59:56 +02:00
pcjones
f3684d24d3 Temporarily remove API key redaction 2024-04-15 00:56:13 +02:00
pcjones
cee3c12daa Add HttpProxyService 2024-04-15 00:48:27 +02:00
pcjones
e888a10366 Add timestamp to log 2024-04-15 00:48:15 +02:00
pcjones
660c245069 Remove 5005 port exposing from docker compose 2024-04-14 23:32:21 +02:00
pcjones
49193ef12f Rename ProxyService to ProxyRequestService 2024-04-14 23:22:40 +02:00
pcjones
881f3b7281 Add IPv6 support 2024-04-14 23:15:03 +02:00
pcjones
d098d1fd10 Change cache time from 5 to 12 minutes 2024-04-14 22:49:50 +02:00
pcjones
6cf87620c3 Add TODO 2024-04-14 22:44:51 +02:00
pcjones
389d685e95 Code cleanup 2024-04-14 22:44:07 +02:00
pcjones
12d9217964 Add todo 2024-04-14 22:29:10 +02:00
pcjones
5cd90b7b20 Remove secrets example 2024-04-14 22:28:38 +02:00
Jonas F
93990dbf52 Merge pull request #16 from PCJones/master
Merge master in develop (readme)
2024-04-14 22:22:26 +02:00
Jonas F
49565be191 Merge pull request #15 from xpsony/Proxy_IOption
Add Proxy Support, Add IOptions Pattern, Add Extensions Method
2024-04-14 22:21:23 +02:00
Felix Glang
24d5cb83a4 Add Proxy Support, Add IOptions Pattern, Add Extensions Method
Currently Changes

Http / Https proxy support has been added , To disguise the Ip address or if a proxy service is required
IOptions pattern has been implemented. Better options handling
Extensions methods have been implemented to make Program.cs smaller
Added a global logger for static and extension methods
appsettings.json now contains "default" data for the applications and proxy settings. The Docker variables are also specified above it. This also fixes the bug that you have to set all variables, although you only want to use Sonarr, for example
2024-04-14 16:43:09 +02:00
pcjones
f02547c0e3 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-03-15 18:25:08 +01:00
pcjones
61e93b5b24 Enhance searchitem matching 2024-03-15 18:24:39 +01:00
pcjones
f88daf4955 Lower minimum delay between requests to 1 second 2024-03-08 10:00:29 +01:00
Jonas F
93c667422f Update README.md 2024-03-06 20:03:16 +01:00
Jonas F
e1978d869c Merge pull request #12 from PCJones/develop
v0.4
2024-03-06 20:00:05 +01:00
pcjones
cfdfa89009 Merge branch 'develop' 2024-03-06 19:52:19 +01:00
pcjones
9bee42d7dd Detect media Type by category ID 2024-03-06 19:52:07 +01:00
pcjones
797ff2b97e Fix searchItem title not being logged 2024-03-01 12:53:47 +01:00
pcjones
a67d5c2d1e Merge branch 'develop' 2024-02-29 17:20:23 +01:00
pcjones
d1d05f8264 Fix url ending with / not being recognized as valid 2024-02-29 17:17:55 +01:00
Jonas F
939b902be3 Update README.md 2024-02-26 21:33:45 +01:00
Jonas F
f56d071642 Update README.md 2024-02-26 21:26:15 +01:00
Jonas F
7513e7d227 Update README.md 2024-02-26 21:23:26 +01:00
Jonas F
7a791dab23 Update README.md 2024-02-26 21:23:00 +01:00
Jonas F
402a4deba3 Update README.md 2024-02-26 21:21:05 +01:00
Jonas F
d31508fef3 Update README.md 2024-02-26 21:20:33 +01:00
pcjones
1c329e886d Update README 2024-02-24 16:33:50 +01:00
pcjones
6932c4b2f8 Add hyphen title matching if title contains colon 2024-02-23 14:09:20 +01:00
pcjones
ff051569ca Fix separator being added twice 2024-02-23 14:08:56 +01:00
pcjones
dbac09bf36 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-23 12:25:51 +01:00
pcjones
0bde5d5d24 Fix sync process not finishing if there was an error 2024-02-23 12:25:42 +01:00
Jonas F
99af842fc6 Update README.md 2024-02-20 15:16:19 +01:00
pcjones
fbfbeadb3e set FORCE_TEXT_SEARCH_ORIGINAL_TITLE to true by default 2024-02-19 21:35:16 +01:00
pcjones
7cfae00511 Fix SearchItem lookup not working for newly added items in Readarr and Lidarr 2024-02-19 21:04:32 +01:00
pcjones
cac920ae88 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-19 14:20:27 +01:00
pcjones
bab60771a4 Merge branch 'develop' 2024-02-19 14:20:10 +01:00
pcjones
828faae486 Lower minimum delay between two requests to 1.5 seconds; no longer use delay if cache can be used 2024-02-19 14:19:57 +01:00
Jonas F
333a18ecd5 Update README.md 2024-02-19 14:07:47 +01:00
pcjones
a4a57d899a Merge branch 'develop' 2024-02-19 14:05:16 +01:00
pcjones
f804dd796f Add title without umlauts as base variation 2024-02-19 06:20:47 +01:00
pcjones
b2e4dbbda6 Made apiKey parameter lowercase 2024-02-19 05:08:24 +01:00
Jonas F
cd70997507 Update README.md 2024-02-18 21:20:39 +01:00
pcjones
b741239194 Lidarr optimizations 2024-02-14 23:59:53 +01:00
pcjones
96f8ff9332 Refactor TItleMatchingService 2024-02-14 21:00:24 +01:00
pcjones
e739affb39 Use TitleMatchVariations instead of TitleSearchVariations in SearchItemByTitle 2024-02-14 20:55:13 +01:00
pcjones
92bdf14618 Remove test variable 2024-02-14 20:40:13 +01:00
pcjones
4260b07bc4 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-14 11:15:51 +01:00
pcjones
4d2ac194aa Ignore case when filtering distinct title match variations 2024-02-14 11:15:44 +01:00
pcjones
a6f332fd99 Fix hyphen in indexer url not being accepted 2024-02-14 11:14:26 +01:00
Jonas F
9c364cb652 Update README.md 2024-02-13 22:34:49 +01:00
38 changed files with 2136 additions and 782 deletions

105
README.md
View File

@@ -2,69 +2,106 @@
## English description coming soon ## English description coming soon
## Erste Testversion ## Beschreibung
Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen! 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 und Lidarr schon Auswirkungen (abgesehen vom Caching). Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr, Readarr und Lidarr schon Auswirkungen (abgesehen vom Caching).
Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden.
[Link zum Docker Image](https://hub.docker.com/r/pcjones/umlautadaptarr)
Zusätzlich müsst ihr in Sonarr oder Prowlarr einen neuen Indexer hinzufügen (für jeden Indexer, bei dem UmlautAdapdarr greifen soll).
Am Beispiel von sceneNZBs:
![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/07c7ca45-e0e5-4a82-af63-365bb23c55c9)
Also alles wie immer, nur dass ihr als API-URL nicht direkt z.B. `https://scenenzbs.com` eingebt, sondern
`http://localhost:5005/_/scenenzbs.com`
Den API-Key müsst ihr natürlich auch ganz normal setzen.
## Was macht UmlautAdaptarr überhaupt?
UmlautAdaptarr löst mehrere Probleme: UmlautAdaptarr löst mehrere Probleme:
- Releases mit Umlauten werden grundsätzlich nicht korrekt von den *Arrs importiert - 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 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. - Sonarr & Radarr erwarten immer den englischen Titel von https://thetvdb.com/ bzw. https://www.themoviedb.org/. Das führt bei deutschen Produktionen oder deutschen Übersetzungen oft zu Problemen - falls die *arrs schon mal etwas mit der Meldung `Found matching series/movie via grab history, but release was matched to series by ID. Automatic import is not possible/` nicht importiert haben, dann war das der Grund.
- Releases mit schlechtem Naming (z.B. von der Group TvR die kein "GERMAN" in den Releasename tun) werden korrigiert, so dass Sonarr&Radarr diese korrekt erkennen (optional)
- Zusätzlich werden einige andere Fehler behoben, die häufig dazu führen, dass Titel nicht erfolgreich gefunden, geladen oder importiert werden.
# Wie macht UmlautAdaptarr das? ## Wie macht UmlautAdaptarr das?
UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten. UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten.
Am Ende werden die gefundenen Releases immer so umbenannt, das die Arrs sie einwandfrei erkennen. Am Ende werden die gefundenen Releases immer so umbenannt, dass die Arrs sie einwandfrei erkennen.
Einige Beispiele findet ihr unter Features. Einige Beispiele finden sich [weiter unten](https://github.com/PCJones/UmlautAdaptarr?tab=readme-ov-file#beispiel-funktionalit%C3%A4t).
## Features ## Features
| Feature | Status | | Feature | Status |
|-------------------------------------------------------------------|---------------| |-------------------------------------------------------------------|---------------|
| Prowlarr Support | ✓| | Prowlarr & NZB Hydra Support | ✓|
| Sonarr Support | ✓ | | Sonarr Support | ✓ |
| Lidarr Support | ✓| | Lidarr Support | ✓|
| Readarr Support | ✓ |
| Releases mit deutschem Titel werden erkannt | ✓ | | Releases mit deutschem Titel werden erkannt | ✓ |
| Releases mit TVDB-Alias Titel werden erkannt | ✓ | | Releases mit TVDB-Alias Titel werden erkannt | ✓ |
| Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ | | Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ |
| Anfragen-Caching für 5 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Anfragen-Caching für 12 Minuten zur Reduzierung der API-Zugriffe | ✓ |
| Radarr Support | Geplant | | Usenet (newznab) Support |✓|
| Readarr Support | Geplant | | 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 | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant |
| Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant |
| Wünsche? | Vorschläge? | | 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.
[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.
### 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:
![Image](https://github.com/PCJones/UmlautAdaptarr/assets/377223/b97418d8-d972-4e3c-9d2f-3a830a5ac0a3)
- Name: UmlautAdaptarr HTTP Proxy (Beispiel)
- Port: `5006` (Port beachten!)
- 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:
![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.
### 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:
Am Beispiel von sceneNZBs:
![grafik](https://github.com/PCJones/UmlautAdaptarr/assets/377223/07c7ca45-e0e5-4a82-af63-365bb23c55c9)
Also alles wie immer, nur dass als API-URL nicht direkt z.B. `https://scenenzbs.com` gesetzt wird, sondern
`http://localhost:5005/_/scenenzbs.com`
Der API-Key muss natürlich auch ganz normal gesetzt werden.
## Beispiel-Funktionalität ## Beispiel-Funktionalität
In den Klammern am Ende des Releasenamens (Bild 2 & 4) steht zu Anschauungszwecken der deutsche Titel der vorher nicht gefunden bzw. akzeptiert wurde. Das bleibt natürlich nicht so ;) In den Klammern am Ende des Releasenamens (Bild 2 & 4) steht zu Anschauungszwecken der deutsche Titel der vorher nicht gefunden bzw. akzeptiert wurde. Das bleibt natürlich nicht so ;)
**Vorher:** Release wird zwar gefunden, kann aber kann nicht zu geordnet werden. **Vorher:** Release wird zwar gefunden, kann aber kann nicht zu geordnet werden.
![Vorherige Suche ohne deutsche Titel](https://i.imgur.com/7pfRzgH.png) ![Vorherige Suche ohne deutsche Titel](https://github.com/PCJones/UmlautAdaptarr/assets/377223/1fce2909-a36c-4f1b-8497-85903357fee3)
**Jetzt:** 2-3 weitere Releases werden gefunden, außerdem meckert Sonarr nicht mehr über den Namen und würde es bei einer automatischen Suche ohne Probleme importieren. **Jetzt:** 2-3 weitere Releases werden gefunden, außerdem meckert Sonarr nicht mehr über den Namen und würde es bei einer automatischen Suche ohne Probleme importieren.
![Jetzige Suche mit deutschen Titeln](https://i.imgur.com/k55YIN9.png) ![Jetzige Suche mit deutschen Titeln](https://github.com/PCJones/UmlautAdaptarr/assets/377223/0edf43ba-2beb-4f22-aaf4-30f9a619dbd6)
**Vorher:** Es werden nur Releases mit dem englischen Titel der Serie gefunden **Vorher:** Es werden nur Releases mit dem englischen Titel der Serie gefunden
![Vorherige Suche, englische Titel](https://i.imgur.com/pbRlOeX.png) ![Vorherige Suche, englische Titel](https://github.com/PCJones/UmlautAdaptarr/assets/377223/ed7ca0fa-ac36-4584-87ac-b29f32dd9ace)
**Jetzt:** Es werden auch Titel mit dem deutschen Namen gefunden :D
![Jetzige Suche, deutsche und englische Titel](https://github.com/PCJones/UmlautAdaptarr/assets/377223/1c2dbe1a-5943-4fc4-91ef-29708082900e)
**Jetzt:** Es werden auch Titel mit dem deutschen Namen gefunden :D (haben nicht alle Suchergebnisse auf den Screenshot gepasst)
![Jetzige Suche, deutsche und englische Titel](https://i.imgur.com/eeq0Voj.png)
**Vorher:** Die deutsche Produktion `Alone - Überlebe die Wildnis` hat auf [TheTVDB](https://thetvdb.com/series/alone-uberlebe-die-wildnis) den Englischen Namen `Alone Germany`. **Vorher:** Die deutsche Produktion `Alone - Überlebe die Wildnis` hat auf [TheTVDB](https://thetvdb.com/series/alone-uberlebe-die-wildnis) den Englischen Namen `Alone Germany`.
@@ -83,9 +120,13 @@ Sonarr erwartet immer den Englischen Namen, der hier natürlich nicht gegeben is
## Kontakt & Support ## Kontakt & Support
- Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst. - Öffne gerne ein Issue auf GitHub falls du Unterstützung benötigst.
- [Telegram](https://t.me/pc_jones) - [Telegram](https://t.me/pc_jones)
- Discord: pcjones1 - Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM)
- Reddit: /u/IreliaIsLife
## Spenden
Über eine Spende freue ich mich natürlich immer :D
PayPal: https://paypal.me/pcjones1
Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!
### Licenses & Metadata source ### Licenses & Metadata source
- TV Metadata source: https://thetvdb.com - TV Metadata source: https://thetvdb.com

View File

@@ -6,9 +6,9 @@ using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Controllers namespace UmlautAdaptarr.Controllers
{ {
public class CapsController(ProxyService proxyService) : ControllerBase public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase
{ {
private readonly ProxyService _proxyService = proxyService; private readonly ProxyRequestService _proxyRequestService = proxyRequestService;
[HttpGet] [HttpGet]
public async Task<IActionResult> Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) public async Task<IActionResult> Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey)
@@ -20,7 +20,7 @@ namespace UmlautAdaptarr.Controllers
var requestUrl = UrlUtilities.BuildUrl(domain, "caps", apikey); var requestUrl = UrlUtilities.BuildUrl(domain, "caps", apikey);
var responseMessage = await _proxyService.ProxyRequestAsync(HttpContext, requestUrl); var responseMessage = await _proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl);
var content = await responseMessage.Content.ReadAsStringAsync(); var content = await responseMessage.Content.ReadAsStringAsync();
var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ?

View File

@@ -6,9 +6,10 @@ using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Controllers namespace UmlautAdaptarr.Controllers
{ {
public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService) : ControllerBase
{ {
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = false; // 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; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options, protected async Task<IActionResult> BaseSearch(string options,
string domain, string domain,
@@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Controllers
return NotFound($"{domain} is not a valid URL."); return NotFound($"{domain} is not a valid URL.");
} }
var initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult; ContentResult? initialSearchResult = await PerformSingleSearchRequest(domain, queryParameters) as ContentResult;
if (initialSearchResult == null) if (initialSearchResult == null)
{ {
return null; return null;
@@ -52,7 +53,7 @@ namespace UmlautAdaptarr.Controllers
var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations); var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations);
string searchQuery = string.Empty; var searchQuery = string.Empty;
if (queryParameters.TryGetValue("q", out string? q)) if (queryParameters.TryGetValue("q", out string? q))
{ {
searchQuery = q ?? string.Empty; searchQuery = q ?? string.Empty;
@@ -95,7 +96,7 @@ namespace UmlautAdaptarr.Controllers
private async Task<IActionResult> PerformSingleSearchRequest(string domain, IDictionary<string, string> queryParameters) private async Task<IActionResult> PerformSingleSearchRequest(string domain, IDictionary<string, string> queryParameters)
{ {
var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters);
var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); var responseMessage = await proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl);
var content = await responseMessage.Content.ReadAsStringAsync(); var content = await responseMessage.Content.ReadAsStringAsync();
var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ? var encoding = responseMessage.Content.Headers.ContentType?.CharSet != null ?
@@ -129,7 +130,7 @@ namespace UmlautAdaptarr.Controllers
{ {
queryParameters["q"] = titleVariation; // Replace the "q" parameter for each variation queryParameters["q"] = titleVariation; // Replace the "q" parameter for each variation
var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters); var requestUrl = UrlUtilities.BuildUrl(domain, queryParameters);
var responseMessage = await proxyService.ProxyRequestAsync(HttpContext, requestUrl); var responseMessage = await proxyRequestService.ProxyRequestAsync(HttpContext, requestUrl);
var content = await responseMessage.Content.ReadAsStringAsync(); var content = await responseMessage.Content.ReadAsStringAsync();
// Only update encoding from the first response // Only update encoding from the first response
@@ -151,11 +152,12 @@ namespace UmlautAdaptarr.Controllers
} }
} }
public class SearchController(ProxyService proxyService, public class SearchController(ProxyRequestService proxyRequestService,
TitleMatchingService titleMatchingService, TitleMatchingService titleMatchingService,
SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyService, titleMatchingService) SearchItemLookupService searchItemLookupService) : SearchControllerBase(proxyRequestService, titleMatchingService)
{ {
public readonly string[] AUDIO_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; 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] [HttpGet]
public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain) public async Task<IActionResult> MovieSearch([FromRoute] string options, [FromRoute] string domain)
@@ -180,11 +182,18 @@ namespace UmlautAdaptarr.Controllers
{ {
if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories)) if (queryParameters.TryGetValue("cat", out string? categories) && !string.IsNullOrEmpty(categories))
{ {
// Search for audio // look for (audio-)book
if (categories.Split(',').Any(category => AUDIO_CATEGORY_IDS.Contains(category))) if (categories.Split(',').Any(category => READARR_CATEGORY_IDS.Contains(category)))
{
var mediaType = "book";
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetReadarrTitleForExternalId());
}
// look for audio (lidarr)
if (searchItem == null && categories.Split(',').Any(category => LIDARR_CATEGORY_IDS.Contains(category)))
{ {
var mediaType = "audio"; var mediaType = "audio";
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.ToLower()); searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetLidarrTitleForExternalId());
} }
} }
} }

View File

@@ -0,0 +1,10 @@
using UmlautAdaptarr.Models;
namespace UmlautAdaptarr.Interfaces;
public interface IArrApplication
{
Task<IEnumerable<SearchItem>> FetchAllItemsAsync();
Task<SearchItem?> FetchItemByExternalIdAsync(string externalId);
Task<SearchItem?> FetchItemByTitleAsync(string title);
}

View File

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

View File

@@ -12,6 +12,7 @@ namespace UmlautAdaptarr.Models
public bool HasUmlaut => Title?.HasUmlauts() ?? false; public bool HasUmlaut => Title?.HasUmlauts() ?? false;
public string ExpectedTitle { get; set; } public string ExpectedTitle { get; set; }
public string? ExpectedAuthor { get; set; } public string? ExpectedAuthor { get; set; }
// TODO rename GermanTitle into Foreign or LocalTitle?
public string? GermanTitle { get; set; } public string? GermanTitle { get; set; }
public string[] TitleSearchVariations { get; set; } public string[] TitleSearchVariations { get; set; }
public string[] TitleMatchVariations { get; set; } public string[] TitleMatchVariations { get; set; }
@@ -36,22 +37,40 @@ namespace UmlautAdaptarr.Models
ExpectedAuthor = expectedAuthor; ExpectedAuthor = expectedAuthor;
GermanTitle = germanTitle; GermanTitle = germanTitle;
MediaType = mediaType; MediaType = mediaType;
if (mediaType == "audio" && expectedAuthor != null) if ((mediaType == "audio" || mediaType == "book") && expectedAuthor != null)
{ {
// e.g. Die Ärzte - best of die Ärzte GenerateVariationsForBooksAndAudio(expectedTitle, mediaType, expectedAuthor);
if (expectedTitle.Contains(expectedAuthor))
{
var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim();
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
} }
else else
{ {
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray(); // if mediatype is movie/tv and the Expected Title ends with a year but the german title doesn't then append the year to the german title and to aliases
// example: https://thetvdb.com/series/385925-avatar-the-last-airbender -> german Title is without 2024
var yearAtEndOfTitleMatch = YearAtEndOfTitleRegex().Match(expectedTitle);
if (yearAtEndOfTitleMatch.Success)
{
string year = yearAtEndOfTitleMatch.Value[1..^1];
if (GermanTitle != null && !GermanTitle.Contains(year))
{
GermanTitle = $"{germanTitle} {year}";
} }
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray(); if (aliases != null)
{
for (int i = 0; i < aliases.Length; i++)
{
if (!aliases[i].Contains(year))
{
aliases[i] = $"{aliases[i]} {year}";
} }
else }
}
}
GenerateVariationsForTV(GermanTitle, mediaType, aliases);
}
}
private void GenerateVariationsForTV(string? germanTitle, string mediaType, string[]? aliases)
{ {
TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray(); TitleSearchVariations = GenerateVariations(germanTitle, mediaType).ToArray();
@@ -64,6 +83,12 @@ namespace UmlautAdaptarr.Models
foreach (var alias in aliases) foreach (var alias in aliases)
{ {
allTitleVariations.AddRange(GenerateVariations(alias, mediaType)); allTitleVariations.AddRange(GenerateVariations(alias, mediaType));
// If title contains ":" also match for "-"
if (alias.Contains(':'))
{
allTitleVariations.Add(alias.Replace(":", " -"));
}
} }
} }
@@ -73,7 +98,8 @@ namespace UmlautAdaptarr.Models
// also add a matching title without (DE) // also add a matching title without (DE)
if (germanTitle?.EndsWith("(DE)") ?? false) if (germanTitle?.EndsWith("(DE)") ?? false)
{ {
TitleSearchVariations = [.. TitleSearchVariations, .. TitleSearchVariations = [.. TitleSearchVariations,
..
GenerateVariations( GenerateVariations(
germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(), germanTitle.Replace("(DE)", " GERMAN").RemoveExtraWhitespaces(),
mediaType)]; mediaType)];
@@ -82,17 +108,77 @@ namespace UmlautAdaptarr.Models
} }
TitleMatchVariations = allTitleVariations.Distinct().ToArray(); // if a german title ends with "Germany" (e.g. Good Luck Guys Germany) also add a search string that replaces Germany with GERMAN
// (e.g. Good Luck Guys GERMAN). This is because reality shows often have different formats in different countries with the same
// name. // also add a matching title without GERMAN
if (germanTitle?.EndsWith("germany", StringComparison.OrdinalIgnoreCase) ?? false)
{
TitleSearchVariations = [.. TitleSearchVariations,
..
GenerateVariations(
(germanTitle[..^7] + "GERMAN").RemoveExtraWhitespaces(),
mediaType)];
allTitleVariations.AddRange(GenerateVariations(germanTitle[..^8].Trim(), mediaType));
}
// If title contains ":" also match for "-"
if (germanTitle?.Contains(':') ?? false)
{
allTitleVariations.Add(germanTitle.Replace(":", " -"));
}
TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray();
}
private void GenerateVariationsForBooksAndAudio(string expectedTitle, string mediaType, string? expectedAuthor)
{
// e.g. Die Ärzte - best of die Ärzte
if (expectedTitle.Contains(expectedAuthor))
{
var titleWithoutAuthorName = expectedTitle.Replace(expectedAuthor, string.Empty).RemoveExtraWhitespaces().Trim();
if (titleWithoutAuthorName.Length < 2)
{
// TODO log warning that this album can't be searched for automatically
}
TitleMatchVariations = GenerateVariations(titleWithoutAuthorName, mediaType).ToArray();
}
else
{
TitleMatchVariations = GenerateVariations(expectedTitle, mediaType).ToArray();
}
TitleSearchVariations = GenerateVariations($"{expectedAuthor} {expectedTitle}", mediaType).ToArray();
AuthorMatchVariations = GenerateVariations(expectedAuthor, mediaType).ToArray();
if (mediaType == "book")
{
if (expectedAuthor?.Contains(' ') ?? false)
{
var nameParts = expectedAuthor.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var lastName = nameParts.Last();
var firstNames = nameParts.Take(nameParts.Length - 1);
var alternativeExpectedAuthor = $"{lastName}, {string.Join(" ", firstNames)}";
AuthorMatchVariations = [.. AuthorMatchVariations, .. GenerateVariations(alternativeExpectedAuthor, mediaType)];
}
} }
} }
private IEnumerable<string> GenerateVariations(string? germanTitle, string mediaType) private static IEnumerable<string> GenerateVariations(string? title, string mediaType)
{ {
if (germanTitle == null) if (title == null)
{
return [];
}
var cleanTitle = title.GetCleanTitle();
if (cleanTitle?.Length == 0)
{ {
return []; return [];
} }
var cleanTitle = germanTitle.RemoveAccentButKeepGermanUmlauts().GetCleanTitle();
// Start with base variations including handling umlauts // Start with base variations including handling umlauts
var baseVariations = new List<string> var baseVariations = new List<string>
@@ -102,6 +188,11 @@ namespace UmlautAdaptarr.Models
cleanTitle.RemoveGermanUmlautDots() cleanTitle.RemoveGermanUmlautDots()
}; };
if (mediaType == "book" || mediaType == "audio")
{
baseVariations.Add(cleanTitle.RemoveGermanUmlauts());
}
// TODO: determine if this is really needed // TODO: determine if this is really needed
// Additional variations to accommodate titles with "-" // Additional variations to accommodate titles with "-"
if (cleanTitle.Contains('-')) if (cleanTitle.Contains('-'))
@@ -121,10 +212,16 @@ namespace UmlautAdaptarr.Models
}); });
} }
// If a german title starts with der/die/das also accept variations without it // If a title starts with der/die/das also accept variations without it
if (mediaType != "audio" && cleanTitle.StartsWith("Der") || cleanTitle.StartsWith("Die") || cleanTitle.StartsWith("Das")) // Same for english the, an, a
if (cleanTitle.StartsWith("Der ") || cleanTitle.StartsWith("Die ") || cleanTitle.StartsWith("Das ")
|| cleanTitle.StartsWith("The ") || cleanTitle.StartsWith("An "))
{ {
var cleanTitleWithoutArticle = germanTitle[3..].Trim(); var cleanTitleWithoutArticle = title[3..].Trim();
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
} else if (cleanTitle.StartsWith("A "))
{
var cleanTitleWithoutArticle = title[2..].Trim();
baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType)); baseVariations.AddRange(GenerateVariations(cleanTitleWithoutArticle, mediaType));
} }
@@ -133,5 +230,8 @@ namespace UmlautAdaptarr.Models
return cleanedVariations.Distinct(); return cleanedVariations.Distinct();
} }
[GeneratedRegex(@"\(\d{4}\)$")]
private static partial Regex YearAtEndOfTitleRegex();
} }
} }

View File

@@ -0,0 +1,25 @@
namespace UmlautAdaptarr.Options.ArrOptions.InstanceOptions
{
public class GlobalInstanceOptions
{
/// <summary>
/// Indicates whether the Arr application is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Name of the Instance
/// </summary>
public string Name { get; set; }
/// <summary>
/// The host of the ARR application.
/// </summary>
public string Host { get; set; }
/// <summary>
/// The API key of the ARR application.
/// </summary>
public string ApiKey { get; set; }
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
namespace UmlautAdaptarr.Options
{
/// <summary>
/// Global options for the UmlautAdaptarr application.
/// </summary>
public class GlobalOptions
{
/// <summary>
/// The host of the UmlautAdaptarr API.
/// </summary>
public string UmlautAdaptarrApiHost { get; set; }
/// <summary>
/// The User-Agent string used in HTTP requests.
/// </summary>
public string UserAgent { get; set; }
}
}

View File

@@ -1,27 +1,42 @@
using Microsoft.Extensions.Configuration;
using System.Net; using System.Net;
using UmlautAdaptarr.Providers; using Serilog;
using Serilog.Filters;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Routing; using UmlautAdaptarr.Routing;
using UmlautAdaptarr.Services; using UmlautAdaptarr.Services;
using UmlautAdaptarr.Services.Factory;
using UmlautAdaptarr.Utilities;
internal class Program internal class Program
{ {
private static void Main(string[] args) private static void Main(string[] args)
{ {
Helper.ShowLogo();
Helper.ShowInformation();
// TODO: // TODO:
// add option to sort by nzb age // add option to sort by nzb age
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration; 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();
builder.Services.AddSerilog();
// Add services to the container. // Add services to the container.
builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() => builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() =>
{ {
var handler = new HttpClientHandler var handler = new HttpClientHandler
{ {
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate |
DecompressionMethods.Brotli
}; };
return handler; return handler;
@@ -29,67 +44,59 @@ internal class Program
builder.Services.AddMemoryCache(options => builder.Services.AddMemoryCache(options =>
{ {
// TODO cache size limit? option?
//options.SizeLimit = 20000; //options.SizeLimit = 20000;
}); });
builder.Services.AllowResolvingKeyedServicesAsDictionary();
// TODO workaround to not log api keys
builder.Logging.AddFilter((category, level) =>
{
// Prevent logging of HTTP request and response if the category is HttpClient
if (category.Contains("System.Net.Http.HttpClient") || category.Contains("Microsoft.Extensions.Http.DefaultHttpClientFactory"))
{
return false;
}
return true;
});
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddHostedService<ArrSyncBackgroundService>(); builder.AddTitleLookupService();
builder.Services.AddSingleton<TitleApiService>();
builder.Services.AddSingleton<SearchItemLookupService>(); builder.Services.AddSingleton<SearchItemLookupService>();
builder.Services.AddSingleton<TitleMatchingService>(); builder.Services.AddSingleton<TitleMatchingService>();
builder.Services.AddSingleton<SonarrClient>(); builder.AddSonarrSupport();
builder.Services.AddSingleton<LidarrClient>(); builder.AddLidarrSupport();
builder.AddReadarrSupport();
builder.Services.AddSingleton<CacheService>(); builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ProxyService>(); builder.Services.AddSingleton<ProxyRequestService>();
builder.Services.AddSingleton<ArrApplicationFactory>();
builder.Services.AddHostedService<ArrSyncBackgroundService>();
builder.Services.AddSingleton<IHostedService, HttpProxyService>();
var app = builder.Build(); var app = builder.Build();
GlobalStaticLogger.Initialize(app.Services.GetService<ILoggerFactory>()!);
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllerRoute(name: "caps", app.MapControllerRoute("caps",
pattern: "{options}/{*domain}", "{options}/{*domain}",
defaults: new { controller = "Caps", action = "Caps" }, new { controller = "Caps", action = "Caps" },
constraints: new { t = new TRouteConstraint("caps") }); new { t = new TRouteConstraint("caps") });
app.MapControllerRoute(name: "movie-search", app.MapControllerRoute("movie-search",
pattern: "{options}/{*domain}", "{options}/{*domain}",
defaults: new { controller = "Search", action = "MovieSearch" }, new { controller = "Search", action = "MovieSearch" },
constraints: new { t = new TRouteConstraint("movie") }); new { t = new TRouteConstraint("movie") });
app.MapControllerRoute(name: "tv-search", app.MapControllerRoute("tv-search",
pattern: "{options}/{*domain}", "{options}/{*domain}",
defaults: new { controller = "Search", action = "TVSearch" }, new { controller = "Search", action = "TVSearch" },
constraints: new { t = new TRouteConstraint("tvsearch") }); new { t = new TRouteConstraint("tvsearch") });
app.MapControllerRoute(name: "music-search", app.MapControllerRoute("music-search",
pattern: "{options}/{*domain}", "{options}/{*domain}",
defaults: new { controller = "Search", action = "MusicSearch" }, new { controller = "Search", action = "MusicSearch" },
constraints: new { t = new TRouteConstraint("music") }); new { t = new TRouteConstraint("music") });
app.MapControllerRoute(name: "book-search", app.MapControllerRoute("book-search",
pattern: "{options}/{*domain}", "{options}/{*domain}",
defaults: new { controller = "Search", action = "BookSearch" }, new { controller = "Search", action = "BookSearch" },
constraints: new { t = new TRouteConstraint("book") }); new { t = new TRouteConstraint("book") });
app.MapControllerRoute(name: "generic-search",
pattern: "{options}/{*domain}",
defaults: new { controller = "Search", action = "GenericSearch" },
constraints: new { t = new TRouteConstraint("search") });
app.MapControllerRoute("generic-search",
"{options}/{*domain}",
new { controller = "Search", action = "GenericSearch" },
new { t = new TRouteConstraint("search") });
app.Run(); app.Run();
} }
} }

View File

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

View File

@@ -1,68 +1,91 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services; using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers namespace UmlautAdaptarr.Providers;
public class LidarrClient : ArrClientBase
{ {
public class LidarrClient( private readonly IMemoryCache _cache;
IHttpClientFactory clientFactory, private readonly CacheService _cacheService;
IConfiguration configuration, private readonly IHttpClientFactory _clientFactory;
CacheService cacheService, private readonly ILogger<LidarrClient> _logger;
IMemoryCache cache,
ILogger<LidarrClient> logger) : ArrClientBase()
{
private readonly string _lidarrHost = configuration.GetValue<string>("LIDARR_HOST") ?? throw new ArgumentException("LIDARR_HOST environment variable must be set");
private readonly string _lidarrApiKey = configuration.GetValue<string>("LIDARR_API_KEY") ?? throw new ArgumentException("LIDARR_API_KEY environment variable must be set");
private readonly string _mediaType = "audio"; private readonly string _mediaType = "audio";
public LidarrClient([ServiceKey] string instanceName,
IHttpClientFactory clientFactory,
CacheService cacheService,
IMemoryCache cache, IOptionsMonitor<LidarrInstanceOptions> options,
ILogger<LidarrClient> logger)
{
_clientFactory = clientFactory;
_cacheService = cacheService;
_cache = cache;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init Lidarr ({InstanceName})");
}
public LidarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync() public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{ {
var httpClient = clientFactory.CreateClient(); var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>(); var items = new List<SearchItem>();
try try
{ {
var lidarrArtistsUrl = $"{_lidarrHost}/api/v1/artist?apikey={_lidarrApiKey}"; var lidarrArtistsUrl = $"{Options.Host}/api/v1/artist?apikey={Options.ApiKey}";
logger.LogInformation($"Fetching all artists from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); _logger.LogInformation(
$"Fetching all artists from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}");
var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl); var artistsApiResponse = await httpClient.GetStringAsync(lidarrArtistsUrl);
var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse); var artists = JsonConvert.DeserializeObject<List<dynamic>>(artistsApiResponse);
if (artists == null) if (artists == null)
{ {
logger.LogError($"Lidarr artists API request resulted in null"); _logger.LogError($"Lidarr ({InstanceName}) artists API request resulted in null");
return items; return items;
} }
logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr.");
_logger.LogInformation($"Successfully fetched {artists.Count} artists from Lidarr ({InstanceName}).");
foreach (var artist in artists) foreach (var artist in artists)
{ {
var artistId = (int)artist.id; var artistId = (int)artist.id;
var lidarrAlbumUrl = $"{_lidarrHost}/api/v1/album?artistId={artistId}&apikey={_lidarrApiKey}"; var lidarrAlbumUrl = $"{Options.Host}/api/v1/album?artistId={artistId}&apikey={Options.ApiKey}";
if (cache.TryGetValue(lidarrAlbumUrl, out List<dynamic>? albums)) // TODO add caching here
{ // Disable cache for now as it can result in problems when adding new albums that aren't displayed on the artists page initially
logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}"); //if (cache.TryGetValue(lidarrAlbumUrl, out List<dynamic>? albums))
} //{
else // logger.LogInformation($"Using cached albums for {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
{ //}
logger.LogInformation($"Fetching all albums from artistId {artistId} from Lidarr: {UrlUtilities.RedactApiKey(lidarrArtistsUrl)}"); //else
//{
_logger.LogInformation(
$"Fetching all albums from artistId {artistId} from Lidarr ({InstanceName}) : {UrlUtilities.RedactApiKey(lidarrAlbumUrl)}");
var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl); var albumApiResponse = await httpClient.GetStringAsync(lidarrAlbumUrl);
albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse); var albums = JsonConvert.DeserializeObject<List<dynamic>>(albumApiResponse);
} //}
if (albums == null) if (albums == null)
{ {
logger.LogWarning($"Lidarr album API request for artistId {artistId} resulted in null"); _logger.LogWarning(
$"Lidarr ({InstanceName}) album API request for artistId {artistId} resulted in null");
continue; continue;
} }
logger.LogInformation($"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr."); _logger.LogInformation(
$"Successfully fetched {albums.Count} albums for artistId {artistId} from Lidarr ({InstanceName}).");
// Cache albums for 3 minutes // Cache albums for 3 minutes
cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3)); _cache.Set(lidarrAlbumUrl, albums, TimeSpan.FromMinutes(3));
foreach (var album in albums) foreach (var album in albums)
{ {
@@ -74,15 +97,15 @@ namespace UmlautAdaptarr.Providers
string[]? aliases = null; string[]? aliases = null;
// Abuse externalId to set the search string Lidarr uses // Abuse externalId to set the search string Lidarr uses
var externalId = expectedTitle.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().RemoveExtraWhitespaces().ToLower(); var externalId = expectedTitle.GetLidarrTitleForExternalId();
var searchItem = new SearchItem var searchItem = new SearchItem
( (
arrId: artistId, artistId,
externalId: externalId, externalId,
title: albumTitle, albumTitle,
expectedTitle: albumTitle, albumTitle,
germanTitle: null, null,
aliases: aliases, aliases: aliases,
mediaType: _mediaType, mediaType: _mediaType,
expectedAuthor: artistName expectedAuthor: artistName
@@ -92,11 +115,11 @@ namespace UmlautAdaptarr.Providers
} }
} }
logger.LogInformation($"Finished fetching all items from Lidarr"); _logger.LogInformation($"Finished fetching all items from Lidarr ({InstanceName})");
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching all artists from Lidarr: {ex.Message}"); _logger.LogError($"Error fetching all artists from Lidarr ({InstanceName}) : {ex.Message}");
} }
return items; return items;
@@ -107,22 +130,22 @@ namespace UmlautAdaptarr.Providers
try try
{ {
// For now we have to fetch all items every time // For now we have to fetch all items every time
// TODO if possible look at the author in search query and only update for author
var searchItems = await FetchAllItemsAsync(); var searchItems = await FetchAllItemsAsync();
foreach (var searchItem in searchItems ?? []) foreach (var searchItem in searchItems ?? [])
{
try try
{ {
cacheService.CacheSearchItem(searchItem); _cacheService.CacheSearchItem(searchItem);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}."); _logger.LogError(ex,
} $"An error occurred while caching search item with ID {searchItem.ArrId} in Lidarr ({InstanceName}).");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}"); _logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}) : {ex.Message}");
} }
return null; return null;
@@ -137,10 +160,9 @@ namespace UmlautAdaptarr.Providers
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching single artist from Lidarr: {ex.Message}"); _logger.LogError($"Error fetching single artist from Lidarr ({InstanceName}): {ex.Message}");
} }
return null; return null;
} }
} }
}

View File

@@ -0,0 +1,186 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers;
public class ReadarrClient : ArrClientBase
{
private readonly IMemoryCache _cache;
private readonly CacheService _cacheService;
private readonly IHttpClientFactory _clientFactory;
private readonly ILogger<ReadarrClient> _logger;
private readonly string _mediaType = "book";
public ReadarrClient([ServiceKey] string instanceName, IHttpClientFactory clientFactory,
CacheService cacheService,
IMemoryCache cache,
IOptionsMonitor<ReadarrInstanceOptions> options,
ILogger<ReadarrClient> logger)
{
_clientFactory = clientFactory;
_cacheService = cacheService;
_cache = cache;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init ReadarrClient ({InstanceName})");
}
public ReadarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{
var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>();
try
{
var readarrAuthorUrl = $"{Options.Host}/api/v1/author?apikey={Options.ApiKey}";
_logger.LogInformation(
$"Fetching all authors from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrAuthorUrl)}");
var authorApiResponse = await httpClient.GetStringAsync(readarrAuthorUrl);
var authors = JsonConvert.DeserializeObject<List<dynamic>>(authorApiResponse);
if (authors == null)
{
_logger.LogError($"Readarr ({InstanceName}) authors API request resulted in null");
return items;
}
_logger.LogInformation($"Successfully fetched {authors.Count} authors from Readarr ({InstanceName}).");
foreach (var author in authors)
{
var authorId = (int)author.id;
var readarrBookUrl = $"{Options.Host}/api/v1/book?authorId={authorId}&apikey={Options.ApiKey}";
// TODO add caching here
_logger.LogInformation(
$"Fetching all books from authorId {authorId} from Readarr ({InstanceName}) : {UrlUtilities.RedactApiKey(readarrBookUrl)}");
var bookApiResponse = await httpClient.GetStringAsync(readarrBookUrl);
var books = JsonConvert.DeserializeObject<List<dynamic>>(bookApiResponse);
if (books == null)
{
_logger.LogWarning(
$"Readarr ({InstanceName}) book API request for authorId {authorId} resulted in null");
continue;
}
_logger.LogInformation(
$"Successfully fetched {books.Count} books for authorId {authorId} from Readarr ({InstanceName}) .");
// Cache books for 3 minutes
_cache.Set(readarrBookUrl, books, TimeSpan.FromMinutes(3));
foreach (var book in books)
{
var authorName = (string)author.authorName;
var bookTitle = GetSearchBookTitle((string)book.title, authorName);
var expectedTitle = $"{bookTitle} {authorName}";
string[]? aliases = null;
// Abuse externalId to set the search string Readarr uses
// TODO use own method or rename
var externalId = expectedTitle.GetReadarrTitleForExternalId();
var searchItem = new SearchItem
(
authorId,
externalId,
bookTitle,
bookTitle,
null,
aliases: aliases,
mediaType: _mediaType,
expectedAuthor: authorName
);
items.Add(searchItem);
}
}
_logger.LogInformation($"Finished fetching all items from Readarr ({InstanceName})");
}
catch (Exception ex)
{
_logger.LogError($"Error fetching all authors from Readarr ({InstanceName}): {ex.Message}");
}
return items;
}
// Logic based on https://github.com/Readarr/Readarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs#L541
public static string GetSearchBookTitle(string bookTitle, string authorName)
{
// Remove author prefix from title if present, e.g., "Tom Clancy: Ghost Protocol"
if (!string.IsNullOrEmpty(authorName) && bookTitle.StartsWith($"{authorName}:"))
bookTitle = bookTitle[(authorName.Length + 1)..].Trim();
// Remove subtitles or additional info enclosed in parentheses or following a colon, if any
var firstParenthesisIndex = bookTitle.IndexOf('(');
var firstColonIndex = bookTitle.IndexOf(':');
if (firstParenthesisIndex > -1)
{
var endParenthesisIndex = bookTitle.IndexOf(')', firstParenthesisIndex);
if (endParenthesisIndex > -1 && bookTitle
.Substring(firstParenthesisIndex + 1, endParenthesisIndex - firstParenthesisIndex - 1)
.Contains(' ')) bookTitle = bookTitle[..firstParenthesisIndex].Trim();
}
else if (firstColonIndex > -1)
{
bookTitle = bookTitle[..firstColonIndex].Trim();
}
return bookTitle;
}
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
{
try
{
// For now we have to fetch all items every time
// TODO if possible look at the author in search query and only update for author
var searchItems = await FetchAllItemsAsync();
foreach (var searchItem in searchItems ?? [])
try
{
_cacheService.CacheSearchItem(searchItem);
}
catch (Exception ex)
{
_logger.LogError(ex, $"An error occurred while caching search item with ID {searchItem.ArrId}.");
}
}
catch (Exception ex)
{
_logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}");
}
return null;
}
public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
{
try
{
// this should never be called at the moment
throw new NotImplementedException();
}
catch (Exception ex)
{
_logger.LogError($"Error fetching single author from Readarr ({InstanceName}) : {ex.Message}");
}
return null;
}
}

View File

@@ -1,52 +1,71 @@
using Newtonsoft.Json; using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
using UmlautAdaptarr.Services; using UmlautAdaptarr.Services;
using UmlautAdaptarr.Utilities; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Providers namespace UmlautAdaptarr.Providers;
public class SonarrClient : ArrClientBase
{ {
public class SonarrClient( private readonly IHttpClientFactory _clientFactory;
IHttpClientFactory clientFactory, private readonly ILogger<SonarrClient> _logger;
IConfiguration configuration,
TitleApiService titleService,
ILogger<SonarrClient> logger) : ArrClientBase()
{
private readonly string _sonarrHost = configuration.GetValue<string>("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set");
private readonly string _sonarrApiKey = configuration.GetValue<string>("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set");
private readonly string _mediaType = "tv"; private readonly string _mediaType = "tv";
private readonly TitleApiService _titleService;
public SonarrClient([ServiceKey] string instanceName,
IHttpClientFactory clientFactory,
TitleApiService titleService,
IOptionsMonitor<SonarrInstanceOptions> options,
ILogger<SonarrClient> logger)
{
_clientFactory = clientFactory;
_titleService = titleService;
_logger = logger;
InstanceName = instanceName;
Options = options.Get(InstanceName);
_logger.LogInformation($"Init SonarrClient ({InstanceName})");
}
public SonarrInstanceOptions Options { get; init; }
public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync() public override async Task<IEnumerable<SearchItem>> FetchAllItemsAsync()
{ {
var httpClient = clientFactory.CreateClient(); var httpClient = _clientFactory.CreateClient();
var items = new List<SearchItem>(); var items = new List<SearchItem>();
try try
{ {
var sonarrUrl = $"{Options.Host}/api/v3/series?includeSeasonImages=false&apikey={Options.ApiKey}";
var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}"; _logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl); var response = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<List<dynamic>>(response); var shows = JsonConvert.DeserializeObject<List<dynamic>>(response);
if (shows != null) if (shows != null)
{ {
logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr."); _logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName}).");
foreach (var show in shows) foreach (var show in shows)
{ {
var tvdbId = (string)show.tvdbId; var tvdbId = (string)show.tvdbId;
if (tvdbId == null) if (tvdbId == null)
{ {
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId."); _logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
continue; continue;
} }
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var (germanTitle, aliases) =
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var searchItem = new SearchItem var searchItem = new SearchItem
( (
arrId: (int)show.id, (int)show.id,
externalId: tvdbId, tvdbId,
title: (string)show.title, (string)show.title,
expectedTitle: (string)show.title, (string)show.title,
germanTitle: germanTitle, germanTitle,
aliases: aliases, aliases: aliases,
mediaType: _mediaType mediaType: _mediaType
); );
@@ -55,11 +74,11 @@ namespace UmlautAdaptarr.Providers
} }
} }
logger.LogInformation($"Finished fetching all items from Sonarr"); _logger.LogInformation($"Finished fetching all items from Sonarr ({InstanceName})");
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching all shows from Sonarr: {ex.Message}"); _logger.LogError($"Error fetching all shows from Sonarr ({InstanceName}) : {ex.Message}");
} }
return items; return items;
@@ -67,12 +86,14 @@ namespace UmlautAdaptarr.Providers
public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId) public override async Task<SearchItem?> FetchItemByExternalIdAsync(string externalId)
{ {
var httpClient = clientFactory.CreateClient(); var httpClient = _clientFactory.CreateClient();
try try
{ {
var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; var sonarrUrl =
logger.LogInformation($"Fetching item by external ID from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); $"{Options.Host}/api/v3/series?tvdbId={externalId}&includeSeasonImages=false&apikey={Options.ApiKey}";
_logger.LogInformation(
$"Fetching item by external ID from Sonarr ({InstanceName}): {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl); var response = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(response); var shows = JsonConvert.DeserializeObject<dynamic>(response);
var show = shows?[0]; var show = shows?[0];
@@ -82,29 +103,31 @@ namespace UmlautAdaptarr.Providers
var tvdbId = (string)show.tvdbId; var tvdbId = (string)show.tvdbId;
if (tvdbId == null) if (tvdbId == null)
{ {
logger.LogWarning($"Sonarr Show {show.id} doesn't have a tvdbId."); _logger.LogWarning($"Sonarr ({InstanceName}) Show {show.id} doesn't have a tvdbId.");
return null; return null;
} }
(var germanTitle, var aliases) = await titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var (germanTitle, aliases) =
await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId);
var searchItem = new SearchItem var searchItem = new SearchItem
( (
arrId: (int)show.id, (int)show.id,
externalId: tvdbId, tvdbId,
title: (string)show.title, (string)show.title,
expectedTitle: (string)show.title, (string)show.title,
germanTitle: germanTitle, germanTitle,
aliases: aliases, aliases: aliases,
mediaType: _mediaType mediaType: _mediaType
); );
logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); _logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
return searchItem; return searchItem;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); _logger.LogError($"Error fetching single show from Sonarr ({InstanceName}): {ex.Message}");
} }
return null; return null;
@@ -112,59 +135,58 @@ namespace UmlautAdaptarr.Providers
public override async Task<SearchItem?> FetchItemByTitleAsync(string title) public override async Task<SearchItem?> FetchItemByTitleAsync(string title)
{ {
var httpClient = clientFactory.CreateClient(); var httpClient = _clientFactory.CreateClient();
try try
{ {
(string? germanTitle, string? tvdbId, string[]? aliases) = await titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title); var (germanTitle, tvdbId, aliases) =
await _titleService.FetchGermanTitleAndExternalIdAndAliasesByTitle(_mediaType, title);
if (tvdbId == null) if (tvdbId == null) return null;
{
return null;
}
var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}"; var sonarrUrl =
$"{Options.Host}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={Options.ApiKey}";
var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl); var sonarrApiResponse = await httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse); var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
if (shows == null) if (shows == null)
{ {
logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null"); _logger.LogError($"Parsing Sonarr ({InstanceName}) API response for TVDB ID {tvdbId} resulted in null");
return null; return null;
} }
else if (shows.Count == 0)
if (shows.Count == 0)
{ {
logger.LogWarning($"No results found for TVDB ID {tvdbId}"); _logger.LogWarning($"No results found for TVDB ID {tvdbId}");
return null; return null;
} }
var expectedTitle = (string)shows[0].title; var expectedTitle = (string)shows[0].title;
if (expectedTitle == null) if (expectedTitle == null)
{ {
logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null"); _logger.LogError($"Sonarr ({InstanceName}) : Title for TVDB ID {tvdbId} is null");
return null; return null;
} }
var searchItem = new SearchItem var searchItem = new SearchItem
( (
arrId: (int)shows[0].id, (int)shows[0].id,
externalId: tvdbId, tvdbId,
title: (string)shows[0].title, (string)shows[0].title,
expectedTitle: (string)shows[0].title, (string)shows[0].title,
germanTitle: germanTitle, germanTitle,
aliases: aliases, aliases: aliases,
mediaType: _mediaType mediaType: _mediaType
); );
logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); _logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr ({InstanceName}).");
return searchItem; return searchItem;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error fetching single show from Sonarr: {ex.Message}"); _logger.LogError($"Error fetching single show from Sonarr ({InstanceName}) : {ex.Message}");
} }
return null; return null;
} }
} }
}

View File

@@ -1,28 +1,21 @@
using Microsoft.Extensions.Caching.Memory; using UmlautAdaptarr.Models;
using Microsoft.Extensions.DependencyInjection; using UmlautAdaptarr.Services.Factory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; namespace UmlautAdaptarr.Services;
using Newtonsoft.Json;
using System;
using System.Threading;
using System.Threading.Tasks;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services
{
public class ArrSyncBackgroundService( public class ArrSyncBackgroundService(
SonarrClient sonarrClient, ArrApplicationFactory arrApplicationFactory,
LidarrClient lidarrClient,
CacheService cacheService, CacheService cacheService,
IConfiguration configuration, ILogger<ArrSyncBackgroundService> logger)
ILogger<ArrSyncBackgroundService> logger) : BackgroundService : BackgroundService
{ {
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED"); public ArrApplicationFactory ArrApplicationFactory { get; } = arrApplicationFactory;
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("ArrSyncBackgroundService is starting."); logger.LogInformation("ArrSyncBackgroundService is starting.");
var lastRunSuccess = true;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -32,14 +25,26 @@ namespace UmlautAdaptarr.Services
if (syncSuccess) if (syncSuccess)
{ {
lastRunSuccess = true;
await Task.Delay(TimeSpan.FromHours(12), stoppingToken); await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
} }
else else
{ {
logger.LogInformation("ArrSyncBackgroundService is sleeping for one hour only because not all syncs were successful."); if (lastRunSuccess)
{
lastRunSuccess = false;
logger.LogInformation(
"ArrSyncBackgroundService is trying again in 2 minutes because not all syncs were successful.");
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
else
{
logger.LogInformation(
"ArrSyncBackgroundService is trying again in one hour only because not all syncs were successful twice in a row.");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken); await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
} }
} }
}
logger.LogInformation("ArrSyncBackgroundService is stopping."); logger.LogInformation("ArrSyncBackgroundService is stopping.");
} }
@@ -49,20 +54,34 @@ namespace UmlautAdaptarr.Services
try try
{ {
var success = true; var success = true;
if (_sonarrEnabled)
if (ArrApplicationFactory.SonarrInstances.Any())
{ {
success = await FetchItemsFromSonarrAsync(); var syncSuccess = await FetchItemsFromSonarrAsync();
success = success && syncSuccess;
} }
if (_lidarrEnabled)
if (ArrApplicationFactory.ReadarrInstances.Any())
{ {
success = await FetchItemsFromLidarrAsync(); var syncSuccess = await FetchItemsFromReadarrAsync();
success = success && syncSuccess;
} }
if (ArrApplicationFactory.ReadarrInstances.Any())
{
var syncSuccess = await FetchItemsFromLidarrAsync();
success = success && syncSuccess;
}
return success; return success;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "An error occurred while fetching items from the Arrs."); logger.LogError(ex, "An error occurred while fetching items from the Arrs.");
} }
return false; return false;
} }
@@ -70,7 +89,15 @@ namespace UmlautAdaptarr.Services
{ {
try try
{ {
var items = await sonarrClient.FetchAllItemsAsync(); var items = new List<SearchItem>();
foreach (var sonarrClient in ArrApplicationFactory.SonarrInstances)
{
var result = await sonarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items); UpdateSearchItems(items);
return items?.Any() ?? false; return items?.Any() ?? false;
} }
@@ -78,6 +105,7 @@ namespace UmlautAdaptarr.Services
{ {
logger.LogError(ex, "An error occurred while updating search item from Sonarr."); logger.LogError(ex, "An error occurred while updating search item from Sonarr.");
} }
return false; return false;
} }
@@ -85,7 +113,14 @@ namespace UmlautAdaptarr.Services
{ {
try try
{ {
var items = await lidarrClient.FetchAllItemsAsync(); var items = new List<SearchItem>();
foreach (var lidarrClient in ArrApplicationFactory.LidarrInstances)
{
var result = await lidarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items); UpdateSearchItems(items);
return items?.Any() ?? false; return items?.Any() ?? false;
} }
@@ -93,13 +128,36 @@ namespace UmlautAdaptarr.Services
{ {
logger.LogError(ex, "An error occurred while updating search item from Lidarr."); logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
} }
return false;
}
private async Task<bool> FetchItemsFromReadarrAsync()
{
try
{
var items = new List<SearchItem>();
foreach (var readarrClient in ArrApplicationFactory.ReadarrInstances)
{
var result = await readarrClient.FetchAllItemsAsync();
items = items.Union(result).ToList();
}
UpdateSearchItems(items);
return items?.Any() ?? false;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while updating search item from Lidarr.");
}
return false; return false;
} }
private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems) private void UpdateSearchItems(IEnumerable<SearchItem>? searchItems)
{ {
foreach (var searchItem in searchItems ?? []) foreach (var searchItem in searchItems ?? [])
{
try try
{ {
cacheService.CacheSearchItem(searchItem); cacheService.CacheSearchItem(searchItem);
@@ -110,5 +168,3 @@ namespace UmlautAdaptarr.Services
} }
} }
} }
}
}

View File

@@ -10,6 +10,7 @@ namespace UmlautAdaptarr.Services
public partial class CacheService(IMemoryCache cache) public partial class CacheService(IMemoryCache cache)
{ {
private readonly Dictionary<string, HashSet<string>> VariationIndex = []; private readonly Dictionary<string, HashSet<string>> VariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> BookVariationIndex = [];
private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = []; private readonly Dictionary<string, List<(HashSet<string> TitleVariations, string CacheKey)>> AudioVariationIndex = [];
private const int VARIATION_LOOKUP_CACHE_LENGTH = 5; private const int VARIATION_LOOKUP_CACHE_LENGTH = 5;
@@ -23,6 +24,11 @@ namespace UmlautAdaptarr.Services
CacheAudioSearchItem(item, cacheKey); CacheAudioSearchItem(item, cacheKey);
return; return;
} }
else if (item.MediaType == "book")
{
CacheBookSearchItem(item, cacheKey);
return;
}
var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedTitle = item.Title.RemoveAccentButKeepGermanUmlauts().ToLower();
@@ -61,42 +67,80 @@ namespace UmlautAdaptarr.Services
} }
} }
public void CacheBookSearchItem(SearchItem item, string cacheKey)
{
// Index author and title variations
foreach (var authorVariation in item.AuthorMatchVariations)
{
var normalizedAuthor = authorVariation.NormalizeForComparison();
if (!BookVariationIndex.ContainsKey(normalizedAuthor))
{
BookVariationIndex[normalizedAuthor] = [];
}
var titleVariations = item.TitleMatchVariations.Select(titleMatchVariation => titleMatchVariation.NormalizeForComparison()).ToHashSet();
BookVariationIndex[normalizedAuthor].Add((titleVariations, cacheKey));
}
}
public SearchItem? SearchItemByTitle(string mediaType, string title) public SearchItem? SearchItemByTitle(string mediaType, string title)
{ {
var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedTitle = title.RemoveAccentButKeepGermanUmlauts().ToLower();
if (mediaType == "audio") if (mediaType == "audio" || mediaType == "book")
{ {
return FindBestMatchForAudio(normalizedTitle.NormalizeForComparison()); return FindBestMatchForBooksAndAudio(normalizedTitle.NormalizeForComparison(), mediaType);
} }
// Use the first few characters of the normalized title for cache prefix search // Use the first few characters of the normalized title for cache prefix search
var cacheSearchPrefix = normalizedTitle[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, normalizedTitle.Length)]; var cacheSearchPrefix = normalizedTitle[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, normalizedTitle.Length)];
SearchItem? bestSearchItemMatch = null;
var bestVariationMatchLength = 0;
HashSet<string> checkedSearchItems = [];
if (VariationIndex.TryGetValue(cacheSearchPrefix, out var cacheKeys)) if (VariationIndex.TryGetValue(cacheSearchPrefix, out var cacheKeys))
{ {
foreach (var cacheKey in cacheKeys) foreach (var cacheKey in cacheKeys)
{ {
if (cache.TryGetValue(cacheKey, out SearchItem? item)) if (cache.TryGetValue(cacheKey, out SearchItem? item))
{ {
if (item?.MediaType != mediaType) if (item == null || item.MediaType != mediaType)
{ {
continue; continue;
} }
var searchItemIdentifier = $"{item.MediaType}_{item.ExternalId}";
if (checkedSearchItems.Contains(searchItemIdentifier))
{
continue;
}
else
{
checkedSearchItems.Add(searchItemIdentifier);
}
// After finding a potential item, compare normalizedTitle with each German title variation // After finding a potential item, compare normalizedTitle with each German title variation
foreach (var variation in item?.TitleSearchVariations ?? []) foreach (var variation in item.TitleMatchVariations ?? [])
{ {
var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower();
if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase))
{ {
return item; // If we find a variation match that is "longer" then most likely that one is correct and the earlier match was wrong (if it was from another searchItem)
if (variation.Length > bestVariationMatchLength)
{
bestSearchItemMatch = item;
bestVariationMatchLength = variation.Length;
}
} }
} }
} }
} }
} }
return null; return bestSearchItemMatch;
} }
public SearchItem? GetSearchItemByExternalId(string mediaType, string externalId) public SearchItem? GetSearchItemByExternalId(string mediaType, string externalId)
@@ -126,9 +170,11 @@ namespace UmlautAdaptarr.Services
return item; return item;
} }
private SearchItem? FindBestMatchForAudio(string normalizedOriginalTitle) private SearchItem? FindBestMatchForBooksAndAudio(string normalizedOriginalTitle, string mediaType)
{ {
foreach (var authorEntry in AudioVariationIndex) var index = mediaType == "audio" ? AudioVariationIndex : BookVariationIndex;
foreach (var authorEntry in index)
{ {
if (normalizedOriginalTitle.Contains(authorEntry.Key)) if (normalizedOriginalTitle.Contains(authorEntry.Key))
{ {

View File

@@ -0,0 +1,77 @@
using UmlautAdaptarr.Interfaces;
using UmlautAdaptarr.Providers;
namespace UmlautAdaptarr.Services.Factory
{
/// <summary>
/// Factory for creating RrApplication instances.
/// </summary>
public class ArrApplicationFactory
{
private readonly ILogger<ArrApplicationFactory> _logger;
/// <summary>
/// Get all IArrApplication instances.
/// </summary>
public IDictionary<string, IArrApplication> AllInstances { get; init; }
/// <summary>
/// Get all SonarrClient instances.
/// </summary>
public IEnumerable<SonarrClient> SonarrInstances { get; init; }
/// <summary>
/// Get all LidarrClient instances.
/// </summary>
public IEnumerable<LidarrClient> LidarrInstances { get; init; }
/// <summary>
/// Get all ReadarrClient instances.
/// </summary>
public IEnumerable<ReadarrClient> ReadarrInstances { get; init; }
/// <summary>
/// Constructor for the ArrApplicationFactory.
/// </summary>
/// <param name="rrArrApplications">A dictionary of IArrApplication instances.</param>
/// <param name="logger">Logger Instanz</param>
public ArrApplicationFactory(IDictionary<string, IArrApplication> rrArrApplications, ILogger<ArrApplicationFactory> logger)
{
_logger = logger;
try
{
SonarrInstances = rrArrApplications.Values.OfType<SonarrClient>();
LidarrInstances = rrArrApplications.Values.OfType<LidarrClient>();
ReadarrInstances = rrArrApplications.Values.OfType<ReadarrClient>();
AllInstances = rrArrApplications;
if (!AllInstances.Values.Any())
{
throw new Exception("No RrApplication 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);
throw;
}
}
/// <summary>
/// Returns an IArrApplication instance that matches the given name.
/// </summary>
/// <param name="nameOfArrInstance">The name of the IArrApplication instance being sought.</param>
/// <returns>The IArrApplication instance that matches the given name.</returns>
/// <exception cref="ArgumentException">Thrown when no IArrApplication instance with the given name can be found.</exception>
public IArrApplication GetArrInstanceByName(string nameOfArrInstance)
{
var instance = AllInstances.FirstOrDefault(up => up.Key.Equals(nameOfArrInstance)).Value;
if (instance == null)
{
throw new ArgumentException($"No ArrService with the name {nameOfArrInstance} could be found");
}
return instance;
}
}
}

View File

@@ -0,0 +1,178 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace UmlautAdaptarr.Services
{
public class HttpProxyService : IHostedService
{
private TcpListener _listener;
private readonly ILogger<HttpProxyService> _logger;
private readonly int _proxyPort = 5006; // TODO move to appsettings.json
private readonly IHttpClientFactory _clientFactory;
private readonly HashSet<string> _knownHosts = [];
private readonly object _hostsLock = new();
private static readonly string[] newLineSeparator = ["\r\n"];
public HttpProxyService(ILogger<HttpProxyService> logger, IHttpClientFactory clientFactory)
{
_logger = logger;
_clientFactory = clientFactory;
_knownHosts.Add("prowlarr.servarr.com");
}
private async Task HandleRequests(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var clientSocket = await _listener.AcceptSocketAsync();
_ = Task.Run(() => ProcessRequest(clientSocket), stoppingToken);
}
}
private async Task ProcessRequest(Socket clientSocket)
{
using var clientStream = new NetworkStream(clientSocket, ownsSocket: true);
var buffer = new byte[8192];
var bytesRead = await clientStream.ReadAsync(buffer);
var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead);
if (requestString.StartsWith("CONNECT"))
{
// Handle HTTPS CONNECT request
await HandleHttpsConnect(requestString, clientStream, clientSocket);
}
else
{
// Handle HTTP request
await HandleHttp(requestString, clientStream, clientSocket, buffer, bytesRead);
}
}
private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket)
{
var (host, port) = ParseTargetInfo(requestString);
// Prowlarr will send grab requests via https which cannot be changed
if (!_knownHosts.Contains(host))
{
_logger.LogWarning($"IMPORTANT! {Environment.NewLine} Indexer {host} needs to be set to http:// instead of https:// {Environment.NewLine}" +
$"UmlautAdaptarr will not work for {host}!");
}
using var targetSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
await targetSocket.ConnectAsync(host, port);
await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection Established\r\n\r\n"));
using var targetStream = new NetworkStream(targetSocket, ownsSocket: true);
await RelayTraffic(clientStream, targetStream);
}
catch (Exception ex)
{
_logger.LogError($"Failed to connect to target: {ex.Message}");
clientSocket.Close();
}
}
private async Task HandleHttp(string requestString, NetworkStream clientStream, Socket clientSocket, byte[] buffer, int bytesRead)
{
try
{
var headers = ParseHeaders(buffer, bytesRead);
string userAgent = headers.FirstOrDefault(h => h.Key == "User-Agent").Value;
var uri = new Uri(requestString.Split(' ')[1]);
// Add to known hosts if not already present
lock (_hostsLock)
{
if (!_knownHosts.Contains(uri.Host))
{
_knownHosts.Add(uri.Host);
}
}
var modifiedUri = $"http://localhost:5005/_/{uri.Host}{uri.PathAndQuery}"; // TODO read port from appsettings?
using var client = _clientFactory.CreateClient();
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri);
httpRequestMessage.Headers.Add("User-Agent", userAgent);
var result = await client.SendAsync(httpRequestMessage);
if (result.IsSuccessStatusCode)
{
var responseData = await result.Content.ReadAsByteArrayAsync();
await clientStream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Length: {responseData.Length}\r\n\r\n"));
await clientStream.WriteAsync(responseData);
}
else
{
await clientStream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {result.StatusCode}\r\n\r\n"));
}
}
catch (Exception ex)
{
_logger.LogError($"HTTP Proxy error: {ex.Message}");
await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 500 Internal Server Error\r\n\r\n"));
}
finally
{
clientSocket.Close();
}
}
private Dictionary<string, string> ParseHeaders(byte[] buffer, int length)
{
var headers = new Dictionary<string, string>();
var headerString = Encoding.ASCII.GetString(buffer, 0, length);
var lines = headerString.Split(newLineSeparator, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines.Skip(1)) // Skip the request line
{
var colonIndex = line.IndexOf(':');
if (colonIndex > 0)
{
var key = line[..colonIndex].Trim();
var value = line[(colonIndex + 1)..].Trim();
headers[key] = value;
}
}
return headers;
}
private static (string host, int port) ParseTargetInfo(string requestLine)
{
var parts = requestLine.Split(' ')[1].Split(':');
return (parts[0], int.Parse(parts[1]));
}
private async Task RelayTraffic(NetworkStream clientStream, NetworkStream targetStream)
{
var clientToTargetTask = RelayStream(clientStream, targetStream);
var targetToClientTask = RelayStream(targetStream, clientStream);
await Task.WhenAll(clientToTargetTask, targetToClientTask);
}
private static async Task RelayStream(NetworkStream input, NetworkStream output)
{
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await input.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, bytesRead));
await output.FlushAsync();
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_listener = new TcpListener(IPAddress.Any, _proxyPort);
_listener.Start();
Task.Run(() => HandleRequests(cancellationToken), cancellationToken);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_listener.Stop();
return Task.CompletedTask;
}
}
}

View File

@@ -1,25 +1,44 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Utilities; using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
public class ProxyService public class ProxyRequestService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly string _userAgent; private readonly string _userAgent;
private readonly ILogger<ProxyService> _logger; private readonly ILogger<ProxyRequestService> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly GlobalOptions _options;
private static readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestTimes = new(); private static readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestTimes = new();
private static readonly TimeSpan MINIMUM_DELAY_FOR_SAME_HOST = new(0, 0, 0, 1);
public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<ProxyService> logger, IMemoryCache cache) public ProxyRequestService(IHttpClientFactory clientFactory, ILogger<ProxyRequestService> logger, IMemoryCache cache, IOptions<GlobalOptions> options)
{ {
_options = options.Value;
_httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(nameof(clientFactory)); _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException(nameof(clientFactory));
_userAgent = configuration["Settings:UserAgent"] ?? throw new ArgumentException("UserAgent must be set in appsettings.json"); _userAgent = _options.UserAgent ?? throw new ArgumentException("UserAgent must be set in appsettings.json");
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
} }
private static async Task EnsureMinimumDelayAsync(string targetUri)
{
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < MINIMUM_DELAY_FOR_SAME_HOST)
{
await Task.Delay(MINIMUM_DELAY_FOR_SAME_HOST - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
}
public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri) public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri)
{ {
if (!HttpMethods.IsGet(context.Request.Method)) if (!HttpMethods.IsGet(context.Request.Method))
@@ -27,18 +46,6 @@ namespace UmlautAdaptarr.Services
throw new ArgumentException("Only GET requests are supported", context.Request.Method); throw new ArgumentException("Only GET requests are supported", context.Request.Method);
} }
// Throttling mechanism
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < TimeSpan.FromSeconds(3))
{
await Task.Delay(TimeSpan.FromSeconds(3) - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
// Check cache // Check cache
if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse)) if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse))
{ {
@@ -46,6 +53,8 @@ namespace UmlautAdaptarr.Services
return cachedResponse!; return cachedResponse!;
} }
await EnsureMinimumDelayAsync(targetUri);
var requestMessage = new HttpRequestMessage var requestMessage = new HttpRequestMessage
{ {
RequestUri = new Uri(targetUri), RequestUri = new Uri(targetUri),
@@ -67,12 +76,12 @@ namespace UmlautAdaptarr.Services
try try
{ {
_logger.LogInformation($"ProxyService GET {UrlUtilities.RedactApiKey(targetUri)}"); _logger.LogInformation($"ProxyRequestService GET {UrlUtilities.RedactApiKey(targetUri)}");
var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
if (responseMessage.IsSuccessStatusCode) if (responseMessage.IsSuccessStatusCode)
{ {
_cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(5)); _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(12));
} }
return responseMessage; return responseMessage;
@@ -81,7 +90,6 @@ namespace UmlautAdaptarr.Services
{ {
_logger.LogError(ex, $"Error proxying request: {UrlUtilities.RedactApiKey(targetUri)}. Error: {ex.Message}"); _logger.LogError(ex, $"Error proxying request: {UrlUtilities.RedactApiKey(targetUri)}. Error: {ex.Message}");
// Create a response message indicating an internal server error
var errorResponse = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError) var errorResponse = new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
{ {
Content = new StringContent($"An error occurred while processing your request: {ex.Message}") Content = new StringContent($"An error occurred while processing your request: {ex.Message}")

View File

@@ -1,12 +1,12 @@
using UmlautAdaptarr.Models; using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers; using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Services.Factory;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
public class SearchItemLookupService(CacheService cacheService, SonarrClient sonarrClient, LidarrClient lidarrClient, IConfiguration configuration) public class SearchItemLookupService(CacheService cacheService,
ArrApplicationFactory arrApplicationFactory)
{ {
private readonly bool _sonarrEnabled = configuration.GetValue<bool>("SONARR_ENABLED");
private readonly bool _lidarrEnabled = configuration.GetValue<bool>("LIDARR_ENABLED");
public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId) public async Task<SearchItem?> GetOrFetchSearchItemByExternalId(string mediaType, string externalId)
{ {
// Attempt to get the item from the cache first // Attempt to get the item from the cache first
@@ -21,15 +21,40 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
if (_sonarrEnabled)
var sonarrInstances = arrApplicationFactory.SonarrInstances;
if (sonarrInstances.Any())
{
foreach (var sonarrClient in sonarrInstances)
{ {
fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId); fetchedItem = await sonarrClient.FetchItemByExternalIdAsync(externalId);
} }
}
break; break;
case "audio": case "audio":
if (_lidarrEnabled)
var lidarrInstances = arrApplicationFactory.LidarrInstances;
if (lidarrInstances.Any())
{ {
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId); foreach (var lidarrClient in lidarrInstances)
{
await lidarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
}
}
break;
case "book":
var readarrInstances = arrApplicationFactory.ReadarrInstances;
if (readarrInstances.Any())
{
foreach (var readarrClient in readarrInstances)
{
await readarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
}
} }
break; break;
} }
@@ -57,13 +82,17 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
if (_sonarrEnabled)
var sonarrInstances = arrApplicationFactory.SonarrInstances;
foreach (var sonarrClient in sonarrInstances)
{ {
fetchedItem = await sonarrClient.FetchItemByTitleAsync(title); fetchedItem = await sonarrClient.FetchItemByTitleAsync(title);
} }
break; break;
case "audio": case "audio":
break; break;
case "book":
break;
// TODO add cases for other sources as needed, such as Radarr, Lidarr, etc. // TODO add cases for other sources as needed, such as Radarr, Lidarr, etc.
} }

View File

@@ -1,24 +1,28 @@
using Newtonsoft.Json; using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UmlautAdaptarr.Options;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services namespace UmlautAdaptarr.Services
{ {
public class TitleApiService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<TitleApiService> logger) public class TitleApiService(IHttpClientFactory clientFactory, ILogger<TitleApiService> logger, IOptions<GlobalOptions> options)
{ {
private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] public GlobalOptions Options { get; } = options.Value;
?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json");
private DateTime lastRequestTime = DateTime.MinValue; private DateTime lastRequestTime = DateTime.MinValue;
private async Task EnsureMinimumDelayAsync() private async Task EnsureMinimumDelayAsync()
{ {
var sinceLastRequest = DateTime.Now - lastRequestTime; var sinceLastRequest = DateTime.Now - lastRequestTime;
if (sinceLastRequest < TimeSpan.FromSeconds(2)) if (sinceLastRequest < TimeSpan.FromSeconds(1))
{ {
await Task.Delay(TimeSpan.FromSeconds(2) - sinceLastRequest); await Task.Delay(TimeSpan.FromSeconds(1) - sinceLastRequest);
} }
lastRequestTime = DateTime.Now; lastRequestTime = DateTime.Now;
} }
// TODO add cache, TODO add bulk request
public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId)
{ {
try try
@@ -26,7 +30,8 @@ namespace UmlautAdaptarr.Services
await EnsureMinimumDelayAsync(); await EnsureMinimumDelayAsync();
var httpClient = clientFactory.CreateClient(); var httpClient = clientFactory.CreateClient();
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}"; var titleApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?tvdbid={externalId}";
logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}");
var response = await httpClient.GetStringAsync(titleApiUrl); var response = await httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(response); var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(response);
@@ -71,7 +76,8 @@ namespace UmlautAdaptarr.Services
var httpClient = clientFactory.CreateClient(); var httpClient = clientFactory.CreateClient();
var tvdbCleanTitle = title.Replace("ß", "ss"); var tvdbCleanTitle = title.Replace("ß", "ss");
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}"; var titleApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}";
logger.LogInformation($"TitleApiService GET {UrlUtilities.RedactApiKey(titleApiUrl)}");
var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl); var titleApiResponse = await httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse); var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse);

View File

@@ -20,7 +20,7 @@ namespace UmlautAdaptarr.Services
if (titleElement != null) if (titleElement != null)
{ {
var originalTitle = titleElement.Value; var originalTitle = titleElement.Value;
var normalizedOriginalTitle = NormalizeTitle(originalTitle); var cleanTitleSeperatedBySpace = ReplaceSeperatorsWithSpace(originalTitle.RemoveAccentButKeepGermanUmlauts());
var categoryElement = item.Element("category"); var categoryElement = item.Element("category");
var category = categoryElement?.Value; var category = categoryElement?.Value;
@@ -34,7 +34,7 @@ namespace UmlautAdaptarr.Services
if (useCacheService) if (useCacheService)
{ {
// Use CacheService to find a matching SearchItem by title // Use CacheService to find a matching SearchItem by title
searchItem = cacheService.SearchItemByTitle(mediaType, normalizedOriginalTitle); searchItem = cacheService.SearchItemByTitle(mediaType, cleanTitleSeperatedBySpace);
} }
if (searchItem == null) if (searchItem == null)
@@ -46,13 +46,16 @@ namespace UmlautAdaptarr.Services
switch (mediaType) switch (mediaType)
{ {
case "tv": case "tv":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!); FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break; break;
case "movie": case "movie":
FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, normalizedOriginalTitle!); FindAndReplaceForMoviesAndTV(logger, searchItem, titleElement, originalTitle, cleanTitleSeperatedBySpace!);
break; break;
case "audio": case "audio":
FindAndReplaceForAudio(searchItem, titleElement, originalTitle!); FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!);
break;
case "book":
FindAndReplaceForBooksAndAudio(searchItem, titleElement, originalTitle!);
break; break;
default: default:
throw new NotImplementedException(); throw new NotImplementedException();
@@ -63,18 +66,17 @@ namespace UmlautAdaptarr.Services
return xDoc.ToString(); return xDoc.ToString();
} }
public void FindAndReplaceForAudio(SearchItem searchItem, XElement? titleElement, string originalTitle) public void FindAndReplaceForBooksAndAudio(SearchItem searchItem, XElement? titleElement, string originalTitle)
{ {
var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); var authorMatch = FindBestMatch(searchItem.AuthorMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
var titleMatch = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle); var (foundMatch, bestStart, bestEndInOriginal) = FindBestMatch(searchItem.TitleMatchVariations, originalTitle.NormalizeForComparison(), originalTitle);
if (authorMatch.Item1 && titleMatch.Item1) if (authorMatch.foundMatch && foundMatch)
{ {
int matchEndPositionInOriginal = Math.Max(authorMatch.Item3, titleMatch.Item3); int matchEndPositionInOriginal = Math.Max(authorMatch.bestEndInOriginal, bestEndInOriginal);
var test = originalTitle[matchEndPositionInOriginal];
// Check and adjust for immediate following delimiter // Check and adjust for immediate following delimiter
char[] delimiters = new char[] { ' ', '-', '_', '.' }; char[] delimiters = [' ', '-', '_', '.'];
if (matchEndPositionInOriginal < originalTitle.Length && delimiters.Contains(originalTitle[matchEndPositionInOriginal])) if (matchEndPositionInOriginal < originalTitle.Length && delimiters.Contains(originalTitle[matchEndPositionInOriginal]))
{ {
matchEndPositionInOriginal++; // Skip the delimiter if it's immediately after the match matchEndPositionInOriginal++; // Skip the delimiter if it's immediately after the match
@@ -84,7 +86,11 @@ namespace UmlautAdaptarr.Services
string suffix = originalTitle[matchEndPositionInOriginal..].TrimStart([' ', '-', '_', '.']).Trim(); string suffix = originalTitle[matchEndPositionInOriginal..].TrimStart([' ', '-', '_', '.']).Trim();
// Concatenate the expected title with the remaining suffix // Concatenate the expected title with the remaining suffix
var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}-[{suffix}]"; var updatedTitle = $"{searchItem.ExpectedAuthor} - {searchItem.ExpectedTitle}";
if (suffix.Length >= 3)
{
updatedTitle += $"-[{suffix}]";
}
// Update the title element // Update the title element
titleElement.Value = updatedTitle; titleElement.Value = updatedTitle;
@@ -92,12 +98,12 @@ namespace UmlautAdaptarr.Services
} }
else else
{ {
logger.LogInformation("TitleMatchingService - No satisfactory fuzzy match found for both author and title."); logger.LogDebug($"TitleMatchingService - No satisfactory fuzzy match found for both author and title for {originalTitle}.");
} }
} }
private Tuple<bool, int, int> FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle) private static (bool foundMatch, int bestStart, int bestEndInOriginal) FindBestMatch(string[] variations, string normalizedOriginal, string originalTitle)
{ {
bool found = false; bool found = false;
int bestStart = int.MaxValue; int bestStart = int.MaxValue;
@@ -120,12 +126,12 @@ namespace UmlautAdaptarr.Services
} }
} }
if (!found) return Tuple.Create(false, 0, 0); if (!found) return (false, 0, 0);
return Tuple.Create(found, bestStart, bestEndInOriginal); return (found, bestStart, bestEndInOriginal);
} }
// Maps an index from the normalized string back to a corresponding index in the original string // Maps an index from the normalized string back to a corresponding index in the original string
private int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex) private static int MapNormalizedIndexToOriginal(string normalizedOriginal, string originalTitle, int normalizedIndex)
{ {
// Count non-special characters up to the given index in the normalized string // Count non-special characters up to the given index in the normalized string
int nonSpecialCharCount = 0; int nonSpecialCharCount = 0;
@@ -160,6 +166,7 @@ namespace UmlautAdaptarr.Services
var titleMatchVariations = searchItem.TitleMatchVariations; var titleMatchVariations = searchItem.TitleMatchVariations;
var expectedTitle = searchItem.ExpectedTitle; var expectedTitle = searchItem.ExpectedTitle;
var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length); var variationsOrderedByLength = titleMatchVariations!.OrderByDescending(variation => variation.Length);
// Attempt to find a variation that matches the start of the original title // Attempt to find a variation that matches the start of the original title
foreach (var variation in variationsOrderedByLength) foreach (var variation in variationsOrderedByLength)
{ {
@@ -199,36 +206,71 @@ namespace UmlautAdaptarr.Services
} }
} }
// Clean up any leading separators from the suffix // Clean up any leading separator from the suffix
suffix = Regex.Replace(suffix, "^[._ ]+", ""); suffix = Regex.Replace(suffix, "^ +", "");
// TODO EVALUTE! definitely make this optional - this adds GERMAN to the title is the title is german to make sure it's recognized as german // TODO add this when radarr is implemented
// can lead to problems with shows such as "dark" that have international dubs // FixBadReleaseNaming
/*
// Check if "german" is not in the original title, ignoring case
if (!Regex.IsMatch(originalTitle, "german", RegexOptions.IgnoreCase))
{
// Insert "GERMAN" after the newTitlePrefix
newTitlePrefix += separator + "GERMAN";
}
*/
// Construct the new title with the original suffix // Construct the new title with the original suffix
var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix); var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : suffix.StartsWith(separator) ? suffix : $"{separator}{suffix}");
// Update the title element's value with the new title // Update the title element's value with the new title
//titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})"; //titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})";
titleElement.Value = newTitle; titleElement.Value = newTitle;
logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'"); logger.LogInformation($"TitleMatchingService - Title changed: '{originalTitle}' to '{newTitle}'");
break; // Break after the first successful match and modification break;
} }
} }
} }
private static string NormalizeTitle(string title) private static readonly string[] MissingGermanTagReleaseGroups = ["tvr"];
private static readonly string[] HEVCInsteadOfx265TagReleaseGroups = ["eisbaer"];
private static readonly string[] WrongTagsReleaseGroups = ["eisbaer"];
private static string FixBadReleaseNaming(string title, string seperator, ILogger<TitleMatchingService> logger)
{
var releaseGroup = GetReleaseGroup(title);
if (MissingGermanTagReleaseGroups.Contains(releaseGroup))
{
// Check if "german" is not in the title, ignoring case
if (!Regex.IsMatch(title, "german", RegexOptions.IgnoreCase))
{
logger.LogInformation($"FixBadReleaseNaming - found missing GERMAN tag for {title}");
// TODO not finished
// Insert "GERMAN" after the newTitlePrefix
//newTitlePrefix += separator + "GERMAN";
}
}
if (HEVCInsteadOfx265TagReleaseGroups.Contains(releaseGroup))
{
if (!title.Contains("REMUX", StringComparison.InvariantCultureIgnoreCase))
{
logger.LogInformation($"FixBadReleaseNaming - found HEVC instead of x265 for {title}");
title = title.Replace("HEVC", "x265");
}
}
if (WrongTagsReleaseGroups.Contains(releaseGroup))
{
if (title.Contains($"{seperator}RM{seperator}"))
{
logger.LogInformation($"FixBadReleaseNaming - found bad Tag RM instead of REMASTERED for {title}");
title = title.Replace($"{seperator}RM{seperator}", $"{seperator}REMASTERED{seperator}");
}
}
return "";
}
private static string? GetReleaseGroup(string title)
{
return title.Contains('-') ? title[(title.LastIndexOf('-') + 1)..].Trim() : null;
}
private static string ReplaceSeperatorsWithSpace(string title)
{ {
title = title.RemoveAccentButKeepGermanUmlauts();
// Replace all known separators with space for normalization // Replace all known separators with space for normalization
return WordSeperationCharRegex().Replace(title, " ".ToString()); return WordSeperationCharRegex().Replace(title, " ".ToString());
} }
@@ -236,7 +278,7 @@ namespace UmlautAdaptarr.Services
private static char FindFirstSeparator(string title) private static char FindFirstSeparator(string title)
{ {
var match = WordSeperationCharRegex().Match(title); var match = WordSeperationCharRegex().Match(title);
return match.Success ? match.Value.First() : ' '; // Default to space if no separator found return match.Success ? match.Value.First() : ' ';
} }
private static string ReconstructTitleWithSeparator(string title, char separator) private static string ReconstructTitleWithSeparator(string title, char separator)
@@ -256,23 +298,23 @@ namespace UmlautAdaptarr.Services
return null; return null;
} }
if (category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase)) if (category == "7000" || category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase))
{ {
return "book"; return "book";
} }
else if (category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase)) else if (category == "2000" || category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase))
{ {
return "movies"; return "movies";
} }
else if (category.StartsWith("TV", StringComparison.OrdinalIgnoreCase)) else if (category == "5000" || category.StartsWith("TV", StringComparison.OrdinalIgnoreCase))
{ {
return "tv"; return "tv";
} }
else if (category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase)) else if (category == "3030" || category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase))
{ {
return "book"; return "book";
} }
else if (category.StartsWith("Audio")) else if (category == "3000" || category.StartsWith("Audio"))
{ {
return "audio"; return "audio";
} }

View File

@@ -1,162 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using UmlautAdaptarr.Models;
using UmlautAdaptarr.Providers;
using UmlautAdaptarr.Utilities;
namespace UmlautAdaptarr.Services
{
public class TitleQueryServiceLegacy(
IMemoryCache memoryCache,
ILogger<TitleQueryServiceLegacy> logger,
IConfiguration configuration,
IHttpClientFactory clientFactory,
SonarrClient sonarrClient)
{
private readonly HttpClient _httpClient = clientFactory.CreateClient("HttpClient") ?? throw new ArgumentNullException();
private readonly string _sonarrHost = configuration.GetValue<string>("SONARR_HOST") ?? throw new ArgumentException("SONARR_HOST environment variable must be set");
private readonly string _sonarrApiKey = configuration.GetValue<string>("SONARR_API_KEY") ?? throw new ArgumentException("SONARR_API_KEY environment variable must be set");
private readonly string _umlautAdaptarrApiHost = configuration["Settings:UmlautAdaptarrApiHost"] ?? throw new ArgumentException("UmlautAdaptarrApiHost must be set in appsettings.json");
/*public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTVDBId(string tvdbId)
{
var sonarrCacheKey = $"SearchItem_Sonarr_{tvdbId}";
if (memoryCache.TryGetValue(sonarrCacheKey, out SearchItem? cachedItem))
{
return (cachedItem?.HasGermanUmlaut ?? false, cachedItem?.GermanTitle, cachedItem?.ExpectedTitle ?? string.Empty);
}
else
{
var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}";
var sonarrApiResponse = await _httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
if (shows == null)
{
logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null");
return (false, null, string.Empty);
}
else if (shows.Count == 0)
{
logger.LogWarning($"No results found for TVDB ID {tvdbId}");
return (false, null, string.Empty);
}
var expectedTitle = (string)shows[0].title;
if (expectedTitle == null)
{
logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null");
return (false, null, string.Empty);
}
string? germanTitle = null;
var hasGermanTitle = false;
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?tvdbid={tvdbId}";
var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse);
if (titleApiResponseData == null)
{
logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for TVDB ID {tvdbId} resulted in null");
return (false, null, string.Empty);
}
if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle))
{
germanTitle = titleApiResponseData.germanTitle;
hasGermanTitle = true;
}
var hasGermanUmlaut = germanTitle?.HasGermanUmlauts() ?? false;
var result = (hasGermanUmlaut, germanTitle, expectedTitle);
memoryCache.Set(showCacheKey, result, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7)
});
return result;
}
}*/
// This method is being used if the *arrs do a search with the "q" parameter (text search)
public async Task<(bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle)> QueryGermanShowTitleByTitle(string title)
{
// TVDB doesn't use ß - TODO: Determine if this is true
var tvdbCleanTitle = title.Replace("ß", "ss");
var cacheKey = $"show_{tvdbCleanTitle}";
if (memoryCache.TryGetValue(cacheKey, out (bool hasGermanUmlaut, string? GermanTitle, string ExpectedTitle) cachedResult))
{
return cachedResult;
}
var titleApiUrl = $"{_umlautAdaptarrApiHost}/tvshow_german.php?title={tvdbCleanTitle}";
var titleApiResponse = await _httpClient.GetStringAsync(titleApiUrl);
var titleApiResponseData = JsonConvert.DeserializeObject<dynamic>(titleApiResponse);
if (titleApiResponseData == null)
{
logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response for title {title} resulted in null");
return (false, null, string.Empty);
}
if (titleApiResponseData.status == "success" && !string.IsNullOrEmpty((string)titleApiResponseData.germanTitle))
{
var tvdbId = (string)titleApiResponseData.tvdbId;
if (tvdbId == null)
{
logger.LogError($"Parsing UmlautAdaptarr TitleQuery API response tvdbId {titleApiResponseData} resulted in null");
return (false, null, string.Empty);
}
var sonarrUrl = $"{_sonarrHost}/api/v3/series?tvdbId={tvdbId}&includeSeasonImages=false&apikey={_sonarrApiKey}";
var sonarrApiResponse = await _httpClient.GetStringAsync(sonarrUrl);
var shows = JsonConvert.DeserializeObject<dynamic>(sonarrApiResponse);
if (shows == null)
{
logger.LogError($"Parsing Sonarr API response for TVDB ID {tvdbId} resulted in null");
return (false, null, string.Empty);
}
else if (shows.Count == 0)
{
logger.LogWarning($"No results found for TVDB ID {tvdbId}");
return (false, null, string.Empty);
}
var expectedTitle = (string)shows[0].title;
if (expectedTitle == null)
{
logger.LogError($"Sonarr Title for TVDB ID {tvdbId} is null");
return (false, null, string.Empty);
}
string germanTitle ;
bool hasGermanTitle;
germanTitle = titleApiResponseData.germanTitle;
hasGermanTitle = true;
var hasGermanUmlaut = germanTitle?.HasUmlauts() ?? false;
var result = (hasGermanUmlaut, germanTitle, expectedTitle);
memoryCache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = hasGermanTitle ? TimeSpan.FromDays(30) : TimeSpan.FromDays(7)
});
return result;
}
else
{
logger.LogWarning($"UmlautAdaptarr TitleQuery {titleApiUrl} didn't succeed.");
return (false, null, string.Empty);
}
}
}
}

View File

@@ -9,8 +9,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<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="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -47,20 +47,64 @@ namespace UmlautAdaptarr.Utilities
return stringBuilder.ToString().Normalize(NormalizationForm.FormC); return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
} }
// TODO possibly replace GetCleanTitle with RemoveSpecialCharacters public static string GetLidarrTitleForExternalId(this string text)
{
text = text.RemoveGermanUmlautDots()
.Replace("-", "")
.GetCleanTitle()
.ToLower();
// Lidarr removes the, an and a at the beginning
return TitlePrefixRegex()
.Replace(text, "")
.RemoveExtraWhitespaces()
.Trim();
}
public static string GetReadarrTitleForExternalId(this string text)
{
text = text.ToLower();
// Readarr removes "the" at the beginning
if (text.StartsWith("the "))
{
text = text[4..];
}
return text.RemoveGermanUmlautDots()
.Replace(".", " ")
.Replace("-", " ")
.Replace(":", " ")
.GetCleanTitle();
}
public static string GetCleanTitle(this string text) public static string GetCleanTitle(this string text)
{ {
return text.Replace("(", "").Replace(")", "").Replace("?","").Replace(":", "").Replace("'", ""); return text
.Replace(".", " ")
.Replace(":", " ")
.RemoveAccentButKeepGermanUmlauts()
.RemoveSpecialCharacters(removeUmlauts: false)
.RemoveExtraWhitespaces()
.Trim();
} }
public static string NormalizeForComparison(this string text) public static string NormalizeForComparison(this string text)
{ {
// TODO see if we can replace RemoveGermanUmlautDots() with RemoveSpecialCharacters(removeUmlauts: false);
return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower(); return text.RemoveGermanUmlautDots().RemoveAccent().RemoveSpecialCharacters().Replace(" ", "").Trim().ToLower();
} }
public static string RemoveSpecialCharacters(this string text) public static string RemoveSpecialCharacters(this string text, bool removeUmlauts = true)
{ {
return SpecialCharactersRegex().Replace(text, ""); if (removeUmlauts)
{
return NoSpecialCharactersExceptHyphenRegex().Replace(text, "");
}
else
{
return NoSpecialCharactersExceptHyphenAndUmlautsRegex().Replace(text, "");
}
} }
@@ -88,6 +132,18 @@ namespace UmlautAdaptarr.Utilities
.Replace("ß", "ss"); .Replace("ß", "ss");
} }
public static string RemoveGermanUmlauts(this string text)
{
return text
.Replace("ö", "")
.Replace("ü", "")
.Replace("ä", "")
.Replace("Ö", "")
.Replace("Ü", "")
.Replace("Ä", "")
.Replace("ß", "");
}
public static string RemoveExtraWhitespaces(this string text) public static string RemoveExtraWhitespaces(this string text)
{ {
return MultipleWhitespaceRegex().Replace(text, " "); return MultipleWhitespaceRegex().Replace(text, " ");
@@ -100,10 +156,16 @@ namespace UmlautAdaptarr.Utilities
return umlauts.Any(text.Contains); return umlauts.Any(text.Contains);
} }
[GeneratedRegex("[^a-zA-Z0-9 ]+", RegexOptions.Compiled)] [GeneratedRegex("[^a-zA-Z0-9 -]+", RegexOptions.Compiled)]
private static partial Regex SpecialCharactersRegex(); private static partial Regex NoSpecialCharactersExceptHyphenRegex();
[GeneratedRegex("[^a-zA-Z0-9 öäüßÖÄÜß-]+", RegexOptions.Compiled)]
private static partial Regex NoSpecialCharactersExceptHyphenAndUmlautsRegex();
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex MultipleWhitespaceRegex(); private static partial Regex MultipleWhitespaceRegex();
[GeneratedRegex(@"\b(the|an|a)\b", RegexOptions.IgnoreCase, "de-DE")]
private static partial Regex TitlePrefixRegex();
} }
} }

View File

@@ -0,0 +1,19 @@
namespace UmlautAdaptarr.Utilities
{
/// <summary>
/// Service for providing a static logger to log errors and information.
/// The GlobalStaticLogger is designed to provide a static logger that can be used to log errors and information.
/// It facilitates logging for both static classes and extension methods.
/// </summary>
public static class GlobalStaticLogger
{
public static ILogger Logger;
/// <summary>
/// Initializes the GlobalStaticLogger with the provided logger factory.
/// </summary>
/// <param name="loggerFactory">The ILoggerFactory instance used to create loggers.</param>
public static void Initialize(ILoggerFactory loggerFactory) => Logger = loggerFactory.CreateLogger("GlobalStaticLogger");
}
}

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,14 @@ namespace UmlautAdaptarr.Utilities
{ {
public partial class UrlUtilities public partial class UrlUtilities
{ {
[GeneratedRegex(@"^(?!http:\/\/)([a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+.*)$")] [GeneratedRegex(@"^(?!http:\/\/)([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+.*)$")]
private static partial Regex UrlMatchingRegex(); private static partial Regex UrlMatchingRegex();
public static bool IsValidDomain(string domain) public static bool IsValidDomain(string domain)
{ {
// RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich // RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich
// Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle // Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle
var regex = UrlMatchingRegex(); var regex = UrlMatchingRegex();
return regex.IsMatch(domain) && !domain.EndsWith("/"); return regex.IsMatch(domain);
} }
public static string BuildUrl(string domain, IDictionary<string, string> queryParameters) public static string BuildUrl(string domain, IDictionary<string, string> queryParameters)
@@ -35,7 +35,7 @@ namespace UmlautAdaptarr.Utilities
if (!string.IsNullOrEmpty(apiKey)) if (!string.IsNullOrEmpty(apiKey))
{ {
queryParameters["apiKey"] = apiKey; queryParameters["apikey"] = apiKey;
} }
return BuildUrl(domain, queryParameters); return BuildUrl(domain, queryParameters);

View File

@@ -0,0 +1,61 @@
using System.Net;
using FluentValidation;
using UmlautAdaptarr.Options.ArrOptions.InstanceOptions;
namespace UmlautAdaptarr.Validator;
public class GlobalInstanceOptionsValidator : AbstractValidator<GlobalInstanceOptions>
{
public GlobalInstanceOptionsValidator()
{
RuleFor(x => x.Enabled).NotNull();
When(x => x.Enabled, () =>
{
RuleFor(x => x.Host)
.NotEmpty().WithMessage("Host is required when Enabled is true.")
.Must(BeAValidUrl).WithMessage("Host/Url must start with http:// or https:// and be a valid address.")
.Must(BeReachable)
.WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings");
RuleFor(x => x.ApiKey)
.NotEmpty().WithMessage("ApiKey is required when Enabled is true.");
});
}
private bool BeAValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
private static bool BeReachable(string url)
{
var endTime = DateTime.Now.AddMinutes(3);
var reachable = false;
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)
break;
}
catch
{
}
// Wait for 15 seconds for next try
Console.WriteLine($"The URL \"{url}\" is not reachable. Next attempt in 15 seconds...");
Thread.Sleep(15000);
}
return reachable;
}
}

View File

@@ -3,18 +3,66 @@
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
},
"Console": {
"TimestampFormat": "yyyy-MM-dd HH:mm:ss::"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Http": {
"Url": "http://*:5005" "Url": "http://[::]:5005"
} }
} }
}, },
// Settings__UserAgent=UmlautAdaptarr/1.0
// Settings__UmlautAdaptarrApiHost=https://umlautadaptarr.pcjones.de/api/v1
"Settings": { "Settings": {
"UserAgent": "UmlautAdaptarr/1.0", "UserAgent": "UmlautAdaptarr/1.0",
"UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1" "UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1"
},
"Sonarr": [
{
// Docker Environment Variables:
// - Sonarr__0__Enabled: true (set to false to disable)
// - Sonarr__0__Name: Name of the Instance (Optional)
// - Sonarr__0__Host: your_sonarr_host_url
// - Sonarr__0__ApiKey: your_sonarr_api_key
"Enabled": false,
"Name": "Sonarr",
"Host": "your_sonarr_host_url",
"ApiKey": "your_sonarr_api_key"
},
{
// Docker Environment Variables:
// - Sonarr__1__Enabled: true (set to false to disable)
// - Sonarr__1__Name: Name of the Instance (Optional)
// - Sonarr__1__Host: your_sonarr_host_url
// - Sonarr__1__ApiKey: your_sonarr_api_key
"Enabled": false,
"Name": "Sonarr 4k",
"Host": "your_other_sonarr_host_url",
"ApiKey": "your_other_sonarr_api_key"
}
],
"Lidarr":
// Docker Environment Variables:
// - Lidarr__Enabled: true (set to false to disable)
// - Lidarr__Host: your_lidarr_host_url
// - Lidarr__ApiKey: your_lidarr_api_key
{
"Enabled": false,
"Host": "your_lidarr_host_url",
"ApiKey": "your_lidarr_api_key"
},
"Readarr": {
// Docker Environment Variables:
// - Readarr__Enabled: true (set to false to disable)
// - Readarr__Host: your_readarr_host_url
// - Readarr__ApiKey: your_readarr_api_key
"Enabled": false,
"Host": "your_readarr_host_url",
"ApiKey": "your_readarr_api_key"
} }
} }

View File

@@ -1,8 +0,0 @@
{
"SONARR_ENABLED": false,
"SONARR_HOST": "http://localhost:8989",
"SONARR_API_KEY": "",
"LIDARR_ENABLED": false,
"LIDARR_HOST": "http://localhost:8686",
"LIDARR_API_KEY": ""
}

4
build_linux.bat Normal file
View File

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

View File

@@ -6,19 +6,20 @@ services:
build: https://github.com/PCJones/UmlautAdaptarr.git#master build: https://github.com/PCJones/UmlautAdaptarr.git#master
image: umlautadaptarr image: umlautadaptarr
restart: unless-stopped restart: unless-stopped
ports:
- "5005:5005" # can be removed if you use the recommended prowlarr+proxy configuration
- "5006:5006" # can be removed if you use the alternative, non-proxy configuration
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- SONARR_ENABLED=false - SONARR__ENABLED=false
- SONARR_HOST=http://localhost:8989 - SONARR__HOST=http://localhost:8989
- SONARR_API_KEY=API_KEY - SONARR__APIKEY=APIKEY
- RADARR_ENABLED=false - RADARR__ENABLED=false
- RADARR_HOST=http://localhost:7878 - RADARR__HOST=http://localhost:7878
- RADARR_API_KEY=API_KEY - RADARR__APIKEY=APIKEY
- READARR_ENABLED=false - READARR__ENABLED=false
- READARR_HOST=http://localhost:8787 - READARR__HOST=http://localhost:8787
- READARR_API_KEY=API_KEY - READARR__APIKEY=APIKEY
- LIDARR_ENABLED=false - LIDARR__ENABLED=false
- LIDARR_HOST=http://localhost:8686 - LIDARR__HOST=http://localhost:8686
- LIDARR_API_KEY=API_KEY - LIDARR__APIKEY=APIKEY
ports:
- "5005":"5005"