Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping a ledger of the first date added per logical medium (movie or episode, identified by provider ids and the series) and restoring it on upgrades. - core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor) - thread-safe ledger with atomic, debounced persistence - event processing via channel and background worker, seed on first start - scheduled task "Ledger synchronisieren", status API, configuration page - xUnit tests and a Jellyfin 12.2 integration test - Gitea workflows: build/test, release with jprm and manifest branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
11 KiB
Markdown
148 lines
11 KiB
Markdown
# Recently Added Guard
|
||
|
||
Jellyfin-Plugin (Jellyfin **12.2**, .NET 10), das Qualitäts-Upgrades von Radarr und Sonarr aus **„Zuletzt hinzugefügt“** fernhält. Dort erscheinen nur noch Filme und Episoden, die wirklich neu sind.
|
||
|
||
## Warum
|
||
|
||
Radarr und Sonarr ersetzen Filme und Episoden automatisch durch bessere Versionen. Die neue Datei bekommt meist einen **neuen Namen**, weil Qualität und Release-Infos darin stehen. Jellyfin identifiziert Items über den absoluten Pfad. Für einen neuen Pfad legt es deshalb ein neues Item an, dessen „Hinzugefügt am“ (`DateCreated`) **jetzt** ist. Das Upgrade landet unter „Zuletzt hinzugefügt“, als wäre es neu.
|
||
|
||
Auch am **selben Pfad** kann das Datum springen. Ändert sich die Datei, setzt Jellyfin `DateCreated` beim nächsten Metadaten-Refresh auf die Erstellungszeit der Datei.
|
||
|
||
## Funktionsweise
|
||
|
||
Das Plugin führt ein **Ledger** mit dem ersten Hinzufügedatum pro **logischem Medium**, also pro Film oder Episode und nicht pro Datei.
|
||
|
||
- **Identität über Alias-Keys** statt über Pfade:
|
||
- Film: `movie:tmdb:{id}`, `movie:imdb:{id}`, `movie:tvdb:{id}`
|
||
- Episode: `ep:tvdb|tmdb|imdb:{Serien-ID}:s{Staffel}e{Episode}` (Doppelfolgen: `s1e1-2`) und immer zusätzlich `ep:series-guid:{Jellyfin-Serien-ID}:…`. Damit greift der Schutz auch bei Serien ohne Provider-IDs.
|
||
- Ein Eintrag sammelt alle Keys. Ein Treffer über **irgendeinen** Key genügt, neue Keys werden ergänzt.
|
||
- **Neues Medium:** Das Plugin legt einen Eintrag an und ändert nichts am Item.
|
||
- **Upgrade:** Ein Item ist jünger als der Ledger-Eintrag (Toleranz 60 s), und das Medium ist entweder noch vorhanden oder wurde vor weniger als **14 Tagen** gelöscht. Dann setzt das Plugin das „Hinzugefügt am“ des Items auf das gespeicherte Datum zurück. Bei aktiviertem NFO-Speichern schreibt Jellyfin dabei auch `<dateadded>` neu.
|
||
- **Rückkehr nach Ablauf der Frist:** Taucht ein Medium erst später als 14 Tage nach dem Löschen wieder auf, gilt es als **neu**.
|
||
- **Älteres Datum** (z. B. aus einer NFO oder manuell vorverlegt): Der Ledger übernimmt immer das Minimum.
|
||
- Das Plugin wertet `ItemAdded`, `ItemUpdated` und `ItemRemoved` im Hintergrund aus, entprellt und ohne Jellyfin zu blockieren. Überschreibt eine noch laufende Metadaten-Aktualisierung das Datum erneut, setzt das Plugin es wieder zurück.
|
||
- **Seed:** Beim ersten Start registriert das Plugin alle vorhandenen Filme und Episoden, ohne etwas zu ändern.
|
||
- **Scheduled Task „Ledger synchronisieren“** (Kategorie „Recently Added Guard“, täglich um 03:00): Er registriert unbekannte Items, trägt gelöschte aus und entfernt Einträge, die länger als Frist + 1 Tag gelöscht sind. Auch er ändert keine Items.
|
||
|
||
Das Ledger liegt unter `<Jellyfin-Config>/plugins/Jellyfin.Plugin.RecentlyAddedGuard/ledger.json`, im Docker-Container also unter `/config/plugins/…`. Nimm die Datei in dein Backup auf. Löschst du sie und startest Jellyfin neu, läuft der Seed erneut.
|
||
|
||
## Installation
|
||
|
||
Jellyfin lädt `manifest.json` und das Plugin-ZIP **ohne Anmeldung**. Das Repository ist deshalb öffentlich.
|
||
|
||
1. Dashboard → **Plugins** → **Repositories** → **+**
|
||
2. Name: `Recently Added Guard`, URL:
|
||
```
|
||
https://git.menzel.center/menzeljonas/jellyfin-plugin-recently-added-guard/raw/branch/manifest/manifest.json
|
||
```
|
||
3. **Katalog** → **Recently Added Guard** → **Installieren**
|
||
4. Jellyfin **neu starten**. Danach läuft der Seed im Hintergrund. Im Log erscheint `Ledger-Seed abgeschlossen …`.
|
||
|
||
Updates erscheinen anschließend wie bei offiziellen Plugins im Katalog bzw. unter „Meine Plugins“.
|
||
|
||
> **Wichtig:** Deinstalliere **„DateAdded Advanced“** (oder ähnliche Plugins), damit nicht zwei Plugins `DateCreated` verändern.
|
||
|
||
## Konfiguration
|
||
|
||
Dashboard → Plugins → Recently Added Guard:
|
||
|
||
| Einstellung | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| Frist in Tagen (`GraceDays`) | 14 | Taucht ein gelöschtes Medium innerhalb dieser Frist wieder auf, gilt es als Upgrade und bekommt das alte Datum. Danach gilt es als neu. Radarr/Sonarr ersetzen Dateien meist innerhalb von Sekunden; die Frist deckt auch längere Lücken ab, etwa wenn der Download des Upgrades erst später fertig wird. |
|
||
| Toleranz in Sekunden (`ToleranceSeconds`) | 60 | Kleinere Abweichungen des Datums werden ignoriert. |
|
||
| Filme schützen (`EnableMovies`) | an | |
|
||
| Episoden schützen (`EnableEpisodes`) | an | |
|
||
| Testmodus (`DryRun`) | aus | Upgrades nur protokollieren, kein Datum ändern. |
|
||
|
||
Die Seite zeigt außerdem die Ledger-Statistik, die Korrekturen seit dem Start, eine Tabelle der letzten 100 Korrekturen (Name, altes Datum, neues Datum, Zeitpunkt) und einen Button **„Ledger synchronisieren“**.
|
||
|
||
Die Daten kommen aus `GET /RecentlyAddedGuard/Status`. `POST /RecentlyAddedGuard/Sync` startet den Scheduled Task. Beide Endpunkte erfordern Administratorrechte.
|
||
|
||
Korrekturen erscheinen im Jellyfin-Log auf Level *Information*:
|
||
|
||
```
|
||
Upgrade erkannt: Matrix (1999) – DateAdded 2026-10-21 21:02:15Z → 2024-01-01 00:00:00Z
|
||
```
|
||
|
||
## Grenzen
|
||
|
||
- **Bereits falsch datierte Items werden nicht korrigiert.** Das Plugin schützt nur vor zukünftigen Upgrades. Geplant für v2 ist ein Abgleich mit der Import-Historie von Radarr/Sonarr. Die Schnittstelle `IExternalDateSource` ist dafür vorbereitet.
|
||
- **Filme ohne TMDb-ID im Ordnernamen** haben beim Hinzufügen noch keine Provider-ID. Sie werden erst nach dem Metadatenabruf erkannt und können bis dahin kurz unter „Zuletzt hinzugefügt“ auftauchen. Abhilfe: In Radarr als Ordnerformat z. B. `{Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}}` verwenden. Jellyfin liest die ID dann direkt aus dem Pfad. Episoden sind über die Serie sofort zuordenbar.
|
||
- **„Neues Item“-Benachrichtigungen** anderer Plugins (z. B. Webhook) feuern weiterhin, weil Jellyfin das Item tatsächlich neu anlegt.
|
||
- **Manuelles Ändern von „Hinzugefügt am“** im Metadaten-Editor: Vorverlegen senkt das gespeicherte Datum. Ein Nachverlegen setzt das Plugin wieder zurück.
|
||
- **Mehrere Dateien desselben Mediums** (alternative Versionen, z. B. eine 4K-Kopie in einer eigenen Bibliothek) teilen sich absichtlich einen Eintrag. Eine später hinzugefügte zweite Version erscheint deshalb nicht als neu.
|
||
- Ändert sich die **Staffel- oder Episodennummer** einer Datei, gilt sie als anderes Medium.
|
||
|
||
## Entwicklung
|
||
|
||
Voraussetzung: .NET SDK 10 oder Docker.
|
||
|
||
```bash
|
||
dotnet build -c Release # Warnungen sind im Release-Build Fehler
|
||
dotnet test
|
||
|
||
# ohne lokales SDK
|
||
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 dotnet test -c Release
|
||
```
|
||
|
||
Aufbau:
|
||
|
||
| Pfad | Inhalt |
|
||
|---|---|
|
||
| `Core/` | Kernlogik ohne Jellyfin-Typen: `Reconciler` (Abgleich), `MediaKeys`, `GuardProcessor` (Korrektur/DryRun), `CorrectionLog`. Dazu `IdentityResolver` (Jellyfin-Item → `MediaIdentity`) |
|
||
| `Ledger/` | `LedgerStore`: Persistenz, Indizes nach Key und Item-ID, thread-safe, atomares und entprelltes Speichern |
|
||
| `Services/` | `GuardHostedService` (Events → Channel → Worker, Seed), `LedgerSynchronizer`, `LibraryItemDateWriter` |
|
||
| `Tasks/` | Scheduled Task „Ledger synchronisieren“ |
|
||
| `Api/` | `GuardController` (`/RecentlyAddedGuard/Status`, `/RecentlyAddedGuard/Sync`) |
|
||
| `Configuration/` | Konfiguration und eingebettete Konfigurationsseite |
|
||
|
||
Kompiliert wird gegen die NuGet-Pakete `Jellyfin.Controller`/`Jellyfin.Model` **12.2.0** (`ExcludeAssets="runtime"`), das ZIP enthält nur die Plugin-DLL. `targetAbi` ist `12.2.0.0`.
|
||
|
||
### Integrationstest mit Jellyfin 12.2
|
||
|
||
`integration-test/it.py` startet eine Wegwerf-Instanz von `jellyfin/jellyfin:12.2` auf `127.0.0.1:18096`. Das Plugin installiert es so, wie es der Katalog tut (`plugins/Recently Added Guard_<Version>/`). Danach erzeugt das Skript per jellyfin-ffmpeg eine Mini-Bibliothek und prüft:
|
||
|
||
1. Upgrade mit neuem Dateinamen (Film mit `{tmdb-…}`, Episode): Das alte Datum bleibt erhalten. Ein echt neuer Film steht oben in `GET /Items/Latest`.
|
||
2. Datei am selben Pfad ersetzt: Das Datum wird zurückgesetzt.
|
||
3. Film ohne ID im Ordnernamen: Die Korrektur erfolgt nach dem Metadatenabruf.
|
||
4. DryRun ändert nichts, eine Rückkehr nach Ablauf der Frist gilt als neu.
|
||
5. Status-API, Konfigurationsseite, Scheduled Task, Persistenz über einen Neustart.
|
||
|
||
```bash
|
||
# Plugin-ZIP bauen (wie die CI) und testen
|
||
docker run --rm -v "$PWD":/src:ro -v "$PWD/artifacts":/out mcr.microsoft.com/dotnet/sdk:10.0 bash -c '
|
||
cp -r /src /work && cd /work && rm -rf */bin */obj
|
||
apt-get update -qq && apt-get install -y -qq python3-venv >/dev/null
|
||
python3 -m venv /opt/jprm && /opt/jprm/bin/pip install -q jprm==1.1.0
|
||
/opt/jprm/bin/jprm plugin build . --output /out --version 1.0.0.0'
|
||
python3 integration-test/it.py artifacts/recently-added-guard_1.0.0.0.zip
|
||
```
|
||
|
||
Von Hand geht es so:
|
||
|
||
1. Jellyfin 12.2 per Docker starten (`docker run -p 8096:8096 -v ./config:/config -v ./media:/media jellyfin/jellyfin:12.2`). Das ZIP nach `config/plugins/Recently Added Guard_1.0.0.0/` entpacken.
|
||
2. Eine Filmbibliothek mit `media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) WEBDL-720p.mkv` anlegen und scannen.
|
||
3. Im Metadaten-Editor „Hinzugefügt am“ auf ein altes Datum setzen. Danach einen anderen Film hinzufügen.
|
||
4. Die Datei in `… Bluray-1080p.mkv` umbenennen bzw. ersetzen und die Bibliothek scannen.
|
||
5. Mit `GET /Items/Latest?userId=…&parentId=<Bibliothek>` prüfen: Matrix steht nicht vorne, sein `DateCreated` ist das alte Datum. Im Log steht `Upgrade erkannt: …`.
|
||
|
||
## Release und CI
|
||
|
||
| Workflow | Auslöser | Wirkung |
|
||
|---|---|---|
|
||
| `.gitea/workflows/build.yaml` | Push (außer Branch `manifest`), Pull Request | `dotnet restore`, `build -c Release` (Warnungen = Fehler), `test` im Container `mcr.microsoft.com/dotnet/sdk:10.0` |
|
||
| `.gitea/workflows/release.yaml` | Tag `v*` | Tests → ZIP mit jprm → Gitea-Release mit ZIP → `manifest.json` auf Branch `manifest` → Prüfung von anonymem Abruf und MD5 |
|
||
|
||
So veröffentlichst du ein Release:
|
||
|
||
```bash
|
||
git tag -a v1.0.1.0 -m "Kurzer Changelog für Jellyfin"
|
||
git push origin v1.0.1.0
|
||
```
|
||
|
||
- Die Version muss vierteilig sein (`vA.B.C.D`). Die CI schreibt sie in Assembly und `meta.json`.
|
||
- Der Changelog kommt aus der Nachricht des annotierten Tags. Bei einem leichtgewichtigen Tag bilden die Commit-Betreffs seit dem letzten Tag den Changelog.
|
||
- Die CI nutzt nur das automatische `GITEA_TOKEN` (`permissions: contents: write`), ein eigenes Secret ist nicht nötig. Commits mit diesem Token starten in Gitea keine weiteren Workflows.
|
||
- `manifest.json` liegt ausschließlich auf dem Branch `manifest` und wird nur von der CI gepflegt. Die neue Version kommt nach vorne, alte Versionen bleiben erhalten (`checksum` = MD5 des ZIP, `targetAbi` = `12.2.0.0`).
|
||
- Die Plugin-GUID `6fdfedef-adde-4ac3-a873-62a057c95574` darf sich nie ändern.
|