2 Commits
Author SHA1 Message Date
Jonas MenzelandClaude Opus 5.5 333ac6f6ce fix(ci): absolute manifest path for jprm and stable release download URL
build / build (push) Successful in 44s
release / release (push) Successful in 1m26s
jprm rejects a bare file name as repository path (it checks the directory
"" for existence). The asset upload answers with an /attachments/<uuid>
link; the manifest now uses /releases/download/<tag>/<file> instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:23:52 +02:00
Jonas MenzelandClaude Opus 5.5 d431486095 feat: Recently Added Guard plugin for Jellyfin 12.2
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s
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>
2026-10-21 23:10:02 +02:00
39 changed files with 4399 additions and 20 deletions

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yml,yaml,json,xml,csproj,props,slnx}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.cs]
# Layout
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
csharp_new_line_before_open_brace = all
csharp_prefer_braces = true:warning
csharp_style_expression_bodied_methods = when_on_single_line:silent
csharp_style_expression_bodied_properties = true:silent
# var only where the type is apparent
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = true:silent
csharp_style_var_elsewhere = true:silent
# Naming: private fields are _camelCase, constants PascalCase
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_camel
dotnet_naming_rule.private_fields_underscore.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel.required_prefix = _
dotnet_naming_style.underscore_camel.capitalization = camel_case
dotnet_naming_rule.constants_pascal.symbols = constants
dotnet_naming_rule.constants_pascal.style = pascal
dotnet_naming_rule.constants_pascal.severity = warning
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.pascal.capitalization = pascal_case
# Code style enforced in the build
dotnet_diagnostic.IDE0005.severity = warning
dotnet_diagnostic.IDE0161.severity = warning
dotnet_diagnostic.IDE0011.severity = warning
# Logging is event-driven and low-volume (like in Jellyfin itself); LoggerMessage delegates would only add noise.
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1873.severity = none
+47
View File
@@ -0,0 +1,47 @@
# Build and test on every push (except the CI-maintained `manifest` branch) and on pull requests.
# Runner: Gitea act_runner with Docker. The job runs in the .NET 10 SDK image, which has no Node.js,
# so JavaScript actions such as actions/checkout cannot run; the checkout uses plain git.
name: build
on:
push:
branches-ignore: [manifest]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
timeout-minutes: 20
defaults:
run:
shell: bash
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
auth="Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
git -c http.extraHeader="$auth" fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
git log -1 --oneline
- name: Restore
run: dotnet restore
# Directory.Build.props treats warnings as errors in Release.
- name: Build
run: dotnet build -c Release --no-restore
- name: Test
run: dotnet test -c Release --no-build
+198
View File
@@ -0,0 +1,198 @@
# Release on tag vA.B.C.D (Jellyfin plugins use four-part versions):
# 1. test, 2. build the plugin ZIP with jprm (DLL + meta.json only), 3. publish a Gitea release
# with the ZIP, 4. add the version to manifest.json on the `manifest` branch (the URL Jellyfin reads),
# 5. check that manifest and ZIP are reachable anonymously and the MD5 matches.
# The automatic GITEA_TOKEN does all of this; Gitea never starts workflows for pushes made with it,
# so the manifest commit triggers no further run.
name: release
on:
push:
tags: ["v*"]
concurrency:
group: recently-added-guard-release
cancel-in-progress: false
# contents: write = Code (manifest branch) + Releases
permissions:
contents: write
env:
MANIFEST_BRANCH: manifest
JPRM_VERSION: "1.1.0"
PLUGIN_GUID: 6fdfedef-adde-4ac3-a873-62a057c95574
jobs:
release:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
timeout-minutes: 30
defaults:
run:
shell: bash
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
DEBIAN_FRONTEND: noninteractive
PYTHONUTF8: "1"
TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
# The SDK image has no Node.js, so actions/checkout cannot run; plain git instead.
- name: Checkout tag
run: |
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git config http.extraHeader "Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
git fetch -q --tags origin "+refs/heads/*:refs/remotes/origin/*"
git checkout -q "$GITHUB_SHA"
git log -1 --oneline
- name: Version and changelog
id: meta
run: |
tag="$GITHUB_REF_NAME"
version="${tag#v}"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag $tag hat nicht das Format vA.B.C.D" >&2
exit 1
fi
# Changelog: message of an annotated tag, otherwise the commit subjects since the previous tag.
: > changelog.md
if [ "$(git cat-file -t "refs/tags/$tag")" = tag ]; then
git tag -l --format='%(contents)' "$tag" | sed '/-----BEGIN PGP SIGNATURE-----/,$d' > changelog.md
fi
if ! grep -q '[^[:space:]]' changelog.md; then
previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null || true)
git log --no-merges --pretty='- %s' "${previous:+$previous..}$tag" > changelog.md
fi
cat changelog.md
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Test
run: dotnet test -c Release
- name: Install jprm
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends python3-venv > /dev/null
python3 -m venv /opt/jprm
/opt/jprm/bin/pip install -q "jprm==$JPRM_VERSION"
- name: Build plugin ZIP
id: build
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
# Version and changelog go through build.yaml into meta.json (only this CI checkout changes).
/opt/jprm/bin/python - "$VERSION" <<'EOF'
import sys
import yaml
with open("build.yaml", encoding="utf-8") as fh:
config = yaml.safe_load(fh)
with open("changelog.md", encoding="utf-8") as fh:
config["changelog"] = fh.read().strip() or "Version " + sys.argv[1]
config["version"] = sys.argv[1]
with open("build.yaml", "w", encoding="utf-8") as fh:
yaml.safe_dump(config, fh, allow_unicode=True, sort_keys=False)
EOF
mkdir -p artifacts
zip=$(/opt/jprm/bin/jprm plugin build . --output artifacts --version "$VERSION")
contents=$(/opt/jprm/bin/python -c 'import sys, zipfile; print(" ".join(sorted(zipfile.ZipFile(sys.argv[1]).namelist())))' "$zip")
if [ "$contents" != "Jellyfin.Plugin.RecentlyAddedGuard.dll meta.json" ]; then
echo "Unerwarteter ZIP-Inhalt: $contents" >&2
exit 1
fi
md5sum "$zip"
echo "zip=$(realpath "$zip")" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$zip")" >> "$GITHUB_OUTPUT"
- name: Publish Gitea release
id: release
env:
VERSION: ${{ steps.meta.outputs.version }}
ZIP: ${{ steps.build.outputs.zip }}
ASSET: ${{ steps.build.outputs.name }}
run: |
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth=(-H "Authorization: token $TOKEN")
json() { /opt/jprm/bin/python -c "import json, sys; data = json.load(sys.stdin); $1" "${@:2}"; }
# A re-run reuses the release of the tag and replaces the asset.
if ! release=$(curl -fsS "${auth[@]}" "$api/releases/tags/$GITHUB_REF_NAME"); then
body=$(/opt/jprm/bin/python -c 'import json, sys; print(json.dumps({"tag_name": sys.argv[1], "name": "Recently Added Guard " + sys.argv[2], "body": open("changelog.md", encoding="utf-8").read()}))' "$GITHUB_REF_NAME" "$VERSION")
release=$(curl -fsS -X POST "${auth[@]}" -H "Content-Type: application/json" -d "$body" "$api/releases")
fi
release_id=$(printf '%s' "$release" | json 'print(data["id"])')
for asset_id in $(printf '%s' "$release" | json '[print(a["id"]) for a in data["assets"] if a["name"] == sys.argv[1]]' "$ASSET"); do
curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release_id/assets/$asset_id"
done
curl -fsS -X POST "${auth[@]}" -F "attachment=@$ZIP" "$api/releases/$release_id/assets?name=$ASSET" > /dev/null
# The upload answers with an /attachments/<uuid> link; the release download URL is stable and readable.
url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME/$ASSET"
echo "Release-Asset: $url"
echo "url=$url" >> "$GITHUB_OUTPUT"
- name: Update manifest.json
env:
VERSION: ${{ steps.meta.outputs.version }}
ZIP: ${{ steps.build.outputs.zip }}
URL: ${{ steps.release.outputs.url }}
run: |
workdir=$(mktemp -d)
if git show-ref --verify --quiet "refs/remotes/origin/$MANIFEST_BRANCH"; then
git worktree add -q --detach "$workdir" "origin/$MANIFEST_BRANCH"
else
git worktree add -q --orphan -b "$MANIFEST_BRANCH" "$workdir"
fi
cd "$workdir"
[ -f manifest.json ] || echo '[]' > manifest.json
# Adds the version in front (sorted descending) and keeps older versions; MD5 of the ZIP as checksum.
# jprm needs an absolute path: for a bare file name it checks the directory "" and fails.
/opt/jprm/bin/jprm repo add "$workdir/manifest.json" "$ZIP" --plugin-url "$URL"
git add manifest.json
if git diff --cached --quiet; then
echo "manifest.json unverändert"
exit 0
fi
git -c user.name="Gitea Actions" -c user.email="actions@noreply.git.menzel.center" \
commit -q -m "manifest: Recently Added Guard $VERSION"
git push -q origin "HEAD:refs/heads/$MANIFEST_BRANCH"
- name: Verify anonymous download and checksum
env:
VERSION: ${{ steps.meta.outputs.version }}
URL: ${{ steps.release.outputs.url }}
run: |
manifest_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/$MANIFEST_BRANCH/manifest.json"
# No credentials: Jellyfin fetches both anonymously.
curl -fsSL "$manifest_url" -o /tmp/manifest.json
curl -fsSL "$URL" -o /tmp/plugin.zip
/opt/jprm/bin/python - "$VERSION" "$URL" <<'EOF'
import hashlib
import json
import os
import sys
version, url = sys.argv[1], sys.argv[2]
with open("/tmp/manifest.json", encoding="utf-8") as fh:
manifest = json.load(fh)
plugin = next(p for p in manifest if p["guid"] == os.environ["PLUGIN_GUID"])
entry = next(v for v in plugin["versions"] if v["version"] == version)
with open("/tmp/plugin.zip", "rb") as fh:
md5 = hashlib.md5(fh.read()).hexdigest()
assert plugin["versions"][0]["version"] == version, "neue Version steht nicht vorne"
assert entry["sourceUrl"] == url, (entry["sourceUrl"], url)
assert entry["checksum"] == md5, (entry["checksum"], md5)
print(f"OK: {version} -> {url}, MD5 {md5}, targetAbi {entry['targetAbi']}")
EOF
echo "Repository-URL für Jellyfin: $manifest_url"
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
artifacts/
TestResults/
*.user
*.suo
.vs/
.idea/
.vscode/
integration-test/.run/
+20
View File
@@ -0,0 +1,20 @@
<Project>
<!-- Shared settings for the plugin and the test project. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- Overwritten by the release workflow (jprm) with the version from the tag. -->
<Version>1.0.0.0</Version>
<Authors>menzeljonas</Authors>
<Deterministic>true</Deterministic>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<!-- Keeps `dotnet publish` of the solution (jprm) from publishing the tests. -->
<IsPublishable>false</IsPublishable>
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard.Tests</RootNamespace>
<!-- Test names use underscores; test classes need no XML docs. -->
<NoWarn>$(NoWarn);CA1707;CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.RecentlyAddedGuard\Jellyfin.Plugin.RecentlyAddedGuard.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,208 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
public sealed class LedgerStoreTests : IDisposable
{
private readonly GuardFixture _fx = new();
public void Dispose() => _fx.Dispose();
// 13
[Fact]
public void SaveAndLoad_RestoresIdenticalState()
{
var series = Guid.NewGuid();
var movieItem = Guid.NewGuid();
var episodeItems = new[] { Guid.NewGuid(), Guid.NewGuid() };
var removedItem = Guid.NewGuid();
_fx.Reconciler.Register(Media.Movie(movieItem, _fx.Now.AddDays(-30), tmdb: "603", imdb: "tt0133093"));
_fx.Reconciler.Register(Media.Episode(episodeItems[0], _fx.Now.AddDays(-7), series, 2, 3, seriesTvdb: "393189"));
_fx.Reconciler.Register(Media.Episode(episodeItems[1], _fx.Now.AddDays(-1), series, 2, 3, seriesTvdb: "393189"));
var removed = Media.Movie(removedItem, _fx.Now.AddDays(-90), tmdb: "604");
_fx.Reconciler.Register(removed);
_fx.Reconciler.OnItemRemoved(removedItem, removed.Keys);
_fx.Store.MarkSynced(_fx.Now);
_fx.Store.Flush();
using var reloaded = _fx.CreateStore();
reloaded.Load();
Assert.True(reloaded.SeedCompleted);
Assert.Equal(_fx.Now, reloaded.LastSyncUtc);
Assert.Equal(_fx.Store.GetStatistics(), reloaded.GetStatistics());
var expected = _fx.Store.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList();
var actual = reloaded.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList();
Assert.Equal(expected.Count, actual.Count);
for (var i = 0; i < expected.Count; i++)
{
Assert.Equal(expected[i].Keys, actual[i].Keys);
Assert.Equal(expected[i].FirstAdded, actual[i].FirstAdded);
Assert.Equal(DateTimeKind.Utc, actual[i].FirstAdded.Kind);
Assert.Equal(expected[i].LastSeen, actual[i].LastSeen);
Assert.Equal(expected[i].RemovedAt, actual[i].RemovedAt);
Assert.Equal(expected[i].LiveItemIds.Order(), actual[i].LiveItemIds.Order());
Assert.Equal(expected[i].DisplayName, actual[i].DisplayName);
}
// Indexes are rebuilt as well.
Assert.Equal(2, Assert.Single(reloaded.FindByKeys(["ep:tvdb:393189:s2e3"])).LiveItemIds.Count);
Assert.NotNull(reloaded.FindByItemId(movieItem));
Assert.Null(reloaded.FindByItemId(removedItem));
Assert.NotNull(Assert.Single(reloaded.FindByKeys(["movie:tmdb:604"])).RemovedAt);
}
[Fact]
public void Flush_ReplacesFileAtomically_WithoutLeavingTempFile()
{
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603"));
_fx.Store.Flush();
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "604"));
_fx.Store.Flush();
Assert.False(File.Exists(_fx.LedgerPath + ".tmp"));
using var document = JsonDocument.Parse(File.ReadAllBytes(_fx.LedgerPath));
Assert.Equal(2, document.RootElement.GetProperty("Entries").GetArrayLength());
}
[Fact]
public void Load_IgnoresLeftoverTempFileOfInterruptedWrite()
{
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603"));
_fx.Store.Flush();
File.WriteAllText(_fx.LedgerPath + ".tmp", "{ \"Entries\": [ { \"Keys\": [\"half-writ");
using var reloaded = _fx.CreateStore();
reloaded.Load();
Assert.Single(reloaded.GetEntries());
Assert.False(File.Exists(_fx.LedgerPath + ".tmp"));
}
[Fact]
public void Load_MovesUnreadableFileAside_AndStartsEmpty()
{
Directory.CreateDirectory(_fx.DirectoryPath);
File.WriteAllText(_fx.LedgerPath, "{ not json");
using var store = _fx.CreateStore();
store.Load();
Assert.Empty(store.GetEntries());
Assert.False(store.SeedCompleted);
Assert.False(File.Exists(_fx.LedgerPath));
Assert.Single(Directory.GetFiles(_fx.DirectoryPath, LedgerStore.DefaultFileName + ".corrupt-*"));
}
[Fact]
public void Load_WithoutFile_YieldsEmptyLedger()
{
using var store = _fx.CreateStore();
store.Load();
Assert.Empty(store.GetEntries());
Assert.False(store.SeedCompleted);
}
[Fact]
public void Keys_AreMatchedCaseInsensitively()
{
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, imdb: "tt0133093"));
Assert.Single(_fx.Store.FindByKeys(["movie:imdb:TT0133093"]));
}
[Fact]
public async Task MarkDirty_IsDebounced_AndWritesAfterTheDelay()
{
var path = Path.Combine(_fx.DirectoryPath, "debounced.json");
using var store = new LedgerStore(path, NullLogger<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromMilliseconds(100));
var reconciler = new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.Instance);
for (var i = 0; i < 50; i++)
{
reconciler.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: i.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
Assert.False(File.Exists(path));
var stopwatch = Stopwatch.StartNew();
while (!File.Exists(path) && stopwatch.Elapsed < TimeSpan.FromSeconds(10))
{
await Task.Delay(25, TestContext.Current.CancellationToken);
}
Assert.True(File.Exists(path));
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
Assert.Equal(50, document.RootElement.GetProperty("Entries").GetArrayLength());
}
[Fact]
public void ConcurrentEvents_KeepLedgerAndIndexesConsistent()
{
var itemIds = Enumerable.Range(0, 4000).Select(_ => Guid.NewGuid()).ToArray();
Parallel.For(0, itemIds.Length, i =>
{
var tmdb = (i % 500).ToString(System.Globalization.CultureInfo.InvariantCulture);
var movie = Media.Movie(itemIds[i], _fx.Now.AddMinutes(-i), tmdb: tmdb);
_fx.Reconciler.OnItemSeen(movie);
if (i % 3 == 0)
{
_fx.Reconciler.OnItemRemoved(itemIds[i], movie.Keys);
}
});
var entries = _fx.Store.GetEntries();
Assert.Equal(500, entries.Count);
var live = itemIds.Where((_, i) => i % 3 != 0).ToArray();
Assert.Equal(live.Length, entries.Sum(e => e.LiveItemIds.Count));
Assert.All(live, id => Assert.NotNull(_fx.Store.FindByItemId(id)));
Assert.All(entries, e => Assert.Equal(e.Keys.Length, e.Keys.Distinct(StringComparer.OrdinalIgnoreCase).Count()));
}
[Fact]
public void Dispose_FlushesPendingChanges()
{
var path = Path.Combine(_fx.DirectoryPath, "dispose.json");
var store = new LedgerStore(path, NullLogger<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromHours(1));
new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.Instance)
.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: "603"));
store.Dispose();
Assert.True(File.Exists(path));
}
[Fact]
public void Load_MergesOverlappingEntriesOfADamagedFile()
{
var item = Guid.NewGuid();
Directory.CreateDirectory(_fx.DirectoryPath);
File.WriteAllText(
_fx.LedgerPath,
$$"""
{"SchemaVersion":1,"SeedCompleted":true,"Entries":[
{"Keys":["movie:tmdb:603"],"FirstAdded":"2025-05-01T00:00:00Z","LastSeen":"2026-01-01T00:00:00Z","LiveItemIds":["{{item}}"],"DisplayName":"A"},
{"Keys":["movie:tmdb:603","movie:imdb:tt0133093"],"FirstAdded":"2024-05-01T00:00:00Z","LastSeen":"2025-01-01T00:00:00Z","RemovedAt":"2025-02-01T00:00:00Z","LiveItemIds":[],"DisplayName":"B"}
]}
""");
using var store = _fx.CreateStore();
store.Load();
var entry = Assert.Single(store.GetEntries());
Assert.Equal(new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc), entry.FirstAdded);
Assert.Equal(2, entry.Keys.Length);
Assert.Null(entry.RemovedAt);
Assert.Same(entry, store.FindByItemId(item));
}
}
@@ -0,0 +1,41 @@
using System;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Xunit;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
public sealed class MediaKeysTests
{
[Fact]
public void ForMovie_ContainsOnlyKnownProviderIds()
{
Assert.Equal(["movie:tmdb:603", "movie:imdb:tt0133093"], MediaKeys.ForMovie(" 603 ", "tt0133093", " "));
Assert.Empty(MediaKeys.ForMovie(null, null, null));
}
[Theory]
[InlineData(1, 5, null, "s1e5")]
[InlineData(1, 5, 5, "s1e5")]
[InlineData(1, 5, 6, "s1e5-6")]
[InlineData(0, 1, null, "s0e1")]
[InlineData(null, 5, null, null)]
[InlineData(1, null, null, null)]
public void EpisodePart_RequiresSeasonAndEpisode(int? season, int? episode, int? episodeEnd, string? expected)
{
Assert.Equal(expected, MediaKeys.EpisodePart(season, episode, episodeEnd));
}
[Fact]
public void ForEpisode_AddsSeriesGuidKeyAlways()
{
var series = Guid.Parse("0f3c9bd2-38a5-4e47-9d2b-8bd3a1a3e7c4");
var keys = MediaKeys.ForEpisode("s2e3", "393189", "83867", null, series);
Assert.Equal(
["ep:tvdb:393189:s2e3", "ep:tmdb:83867:s2e3", "ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"],
keys);
Assert.Equal(["ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"], MediaKeys.ForEpisode("s2e3", null, null, null, series));
Assert.Empty(MediaKeys.ForEpisode("s2e3", null, null, null, Guid.Empty));
}
}
@@ -0,0 +1,420 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Xunit;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
public sealed class ReconcilerTests : IDisposable
{
private static readonly Guid _oldItem = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid _newItem = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid _otherItem = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid _series = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
private readonly GuardFixture _fx = new();
private static DateTime OriginalDate => GuardFixture.Start.UtcDateTime.AddDays(-300);
public void Dispose() => _fx.Dispose();
// 1
[Fact]
public async Task NewMedium_IsRegistered_AndNotChanged()
{
var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.Registered, result.Action);
Assert.Empty(_fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Equal(_fx.Now, entry.FirstAdded);
Assert.Equal([_oldItem], entry.LiveItemIds);
Assert.Equal(["movie:tmdb:603"], entry.Keys);
}
// 2
[Fact]
public async Task Upgrade_WithNewPath_AfterOldItemRemoved_WithinGrace_GetsOriginalDate()
{
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(old);
_fx.Time.Advance(TimeSpan.FromDays(3));
Assert.True(_fx.Processor.ProcessRemoved(_oldItem, old.Keys));
Assert.Equal(_fx.Now, _fx.SingleEntry().RemovedAt);
_fx.Time.Advance(TimeSpan.FromMinutes(2));
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Null(entry.RemovedAt);
Assert.Equal([_newItem], entry.LiveItemIds);
Assert.Equal(OriginalDate, entry.FirstAdded);
var correction = Assert.Single(_fx.Corrections.GetRecent());
Assert.Equal(_fx.Now, correction.PreviousDate);
Assert.Equal(OriginalDate, correction.RestoredDate);
Assert.False(correction.DryRun);
Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart);
}
// 3
[Fact]
public async Task Upgrade_BeforeOldItemIsRemoved_BothLive_GetsOriginalDate()
{
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(old);
_fx.Time.Advance(TimeSpan.FromDays(10));
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
Assert.Equal(2, _fx.SingleEntry().LiveItemIds.Count);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
var entry = _fx.SingleEntry();
Assert.Equal([_newItem], entry.LiveItemIds);
Assert.Null(entry.RemovedAt);
}
// 4
[Fact]
public async Task SamePath_FileReplaced_DateJumpsOnUpdate_IsReset()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
_fx.Time.Advance(TimeSpan.FromDays(1));
// Metadata refresh noticed the new file and set DateCreated to its creation time.
var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Equal([(_oldItem, OriginalDate)], _fx.Writer.Writes);
// The ItemUpdated caused by the correction itself is a no-op.
var echo = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
Assert.Equal(ReconcileAction.Unchanged, echo.Action);
Assert.Single(_fx.Writer.Writes);
}
// 5
[Fact]
public async Task ReturnAfterGracePeriod_CountsAsNew_WithoutCorrection()
{
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
_fx.Time.Advance(TimeSpan.FromDays(15));
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.ReturnedAfterGrace, result.Action);
Assert.Empty(_fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Equal(_fx.Now, entry.FirstAdded);
Assert.Null(entry.RemovedAt);
}
[Theory]
[InlineData(13, ReconcileAction.UpgradeDetected)]
[InlineData(15, ReconcileAction.ReturnedAfterGrace)]
public async Task GracePeriod_DecidesBetweenUpgradeAndNew(int daysAway, ReconcileAction expected)
{
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
_fx.Time.Advance(TimeSpan.FromDays(daysAway));
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(expected, result.Action);
}
// 6
[Fact]
public async Task OlderDateFromNfo_LowersFirstAdded()
{
await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
Assert.Equal(ReconcileAction.FirstAddedLowered, result.Action);
Assert.Empty(_fx.Writer.Writes);
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
_fx.Time.Advance(TimeSpan.FromDays(1));
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
}
// 7
[Fact]
public async Task MovieWithoutProviderIds_IsIgnoredOnAdd_AndMatchedOnUpdateWithTmdbId()
{
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
_fx.Time.Advance(TimeSpan.FromMinutes(1));
var added = await SeenAsync(Media.Movie(_newItem, _fx.Now));
Assert.Equal(ReconcileAction.Ignored, added.Action);
Assert.Single(_fx.Store.GetEntries());
Assert.Empty(_fx.Writer.Writes);
_fx.Time.Advance(TimeSpan.FromMinutes(1));
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now.AddMinutes(-1), tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
}
// 8
[Fact]
public async Task Episode_MatchesViaSeriesGuid_WithoutProviderIds()
{
var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1);
Assert.Equal(["ep:series-guid:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:s1e1"], old.Keys);
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
var result = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
var otherEpisode = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 2));
Assert.Equal(ReconcileAction.Registered, otherEpisode.Action);
}
// 9
[Fact]
public async Task DoubleEpisode_IsIdentifiedByIndexNumberEnd()
{
var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189");
Assert.Contains("ep:tvdb:393189:s1e1-2", old.Keys);
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
var upgrade = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189"));
Assert.Equal(ReconcileAction.UpgradeDetected, upgrade.Action);
// A single episode 1 is a different medium than the double episode 1-2.
var single = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 1, seriesTvdb: "393189"));
Assert.Equal(ReconcileAction.Registered, single.Action);
Assert.Single(_fx.Writer.Writes);
}
// 10
[Fact]
public async Task AliasKeys_ImdbOnlyEntry_IsMatchedByUpgradeWithTmdbAndImdb_AndKeysAreMerged()
{
var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093");
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Equal(["movie:imdb:tt0133093", "movie:tmdb:603"], entry.Keys.Order());
Assert.Same(entry, Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:603"])));
}
[Fact]
public async Task AliasKeys_SeparateEntriesAreMerged_WhenAnItemBridgesThem()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093"));
_fx.Time.Advance(TimeSpan.FromDays(2));
// Added with the TMDb id from the folder name only: no overlap yet, so it looks new.
var added = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.Registered, added.Action);
Assert.Equal(2, _fx.Store.GetEntries().Count);
// The metadata refresh brings the IMDb id and bridges both entries.
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action);
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Equal(OriginalDate, entry.FirstAdded);
Assert.Equal(2, entry.LiveItemIds.Count);
Assert.Equal(2, entry.Keys.Length);
}
[Fact]
public async Task AliasKeys_EntryPastGracePeriod_OnlyContributesKeysWhenMerged()
{
var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093");
await SeenAsync(old);
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
_fx.Time.Advance(TimeSpan.FromDays(20));
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
Assert.Equal(ReconcileAction.Unchanged, updated.Action);
Assert.Empty(_fx.Writer.Writes);
var entry = _fx.SingleEntry();
Assert.Equal(_fx.Now, entry.FirstAdded);
Assert.Null(entry.RemovedAt);
}
// 11
[Fact]
public async Task DryRun_DetectsUpgrade_ButChangesNothing()
{
_fx.Options = GuardOptions.Default with { DryRun = true };
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
_fx.Time.Advance(TimeSpan.FromDays(1));
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Empty(_fx.Writer.Writes);
Assert.Equal(0, _fx.Corrections.CorrectionsSinceStart);
Assert.Equal(1, _fx.Corrections.DryRunDetectionsSinceStart);
Assert.True(Assert.Single(_fx.Corrections.GetRecent()).DryRun);
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
}
// 12
[Fact]
public async Task Prune_RemovesEntriesRemovedLongerThanGracePlusOneDay()
{
var gone = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
await SeenAsync(gone);
await SeenAsync(Media.Movie(_otherItem, OriginalDate, tmdb: "604"));
_fx.Processor.ProcessRemoved(_oldItem, gone.Keys);
_fx.Time.Advance(TimeSpan.FromDays(14) + TimeSpan.FromHours(23));
Assert.Equal(0, _fx.Reconciler.Prune());
_fx.Time.Advance(TimeSpan.FromHours(2));
Assert.Equal(1, _fx.Reconciler.Prune());
var remaining = _fx.SingleEntry();
Assert.Equal(["movie:tmdb:604"], remaining.Keys);
Assert.Empty(_fx.Store.FindByKeys(gone.Keys));
}
[Fact]
public async Task RepeatedCorrectionOfTheSameUpgrade_IsAppliedAgain_ButCountedOnce()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
var upgrade = Media.Movie(_newItem, _fx.Now, tmdb: "603", name: "The Matrix (1999) {tmdb-603}");
await SeenAsync(upgrade);
// A metadata refresh that was already running saves the item with its new date once more.
await SeenAsync(upgrade with { DisplayName = "Matrix (1999)" });
Assert.Equal([(_newItem, OriginalDate), (_newItem, OriginalDate)], _fx.Writer.Writes);
Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart);
Assert.Equal("Matrix (1999)", Assert.Single(_fx.Corrections.GetRecent()).Name);
}
[Fact]
public async Task ChangeWithinTolerance_IsNoUpgrade()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
var result = await SeenAsync(Media.Movie(_newItem, OriginalDate.AddSeconds(45), tmdb: "603"));
Assert.Equal(ReconcileAction.Unchanged, result.Action);
Assert.Empty(_fx.Writer.Writes);
}
[Fact]
public async Task MissingDateCreated_IsTreatedAsNow()
{
var result = await SeenAsync(Media.Movie(_oldItem, DateTime.MinValue, tmdb: "603"));
Assert.Equal(ReconcileAction.Registered, result.Action);
Assert.Equal(_fx.Now, _fx.SingleEntry().FirstAdded);
}
[Fact]
public async Task UpgradeOfDeletedItem_IsNotRecorded()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
_fx.Writer.ItemExists = false;
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
Assert.Empty(_fx.Corrections.GetRecent());
}
[Fact]
public async Task ReidentifiedItem_MovesToEntryOfItsNewKeys()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "1"));
// "Identify" corrected a wrong match: same item, other film.
var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
Assert.Equal(ReconcileAction.Registered, result.Action);
var entry = _fx.SingleEntry();
Assert.Equal(["movie:tmdb:603"], entry.Keys);
Assert.Equal(OriginalDate, entry.FirstAdded);
}
[Fact]
public async Task Removal_FallsBackToKeys_WhenItemWasNeverRegistered()
{
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
Assert.True(_fx.Processor.ProcessRemoved(_otherItem, MediaKeys.ForMovie("603", null, null)));
Assert.Null(_fx.SingleEntry().RemovedAt);
Assert.False(_fx.Processor.ProcessRemoved(_otherItem, []));
}
[Fact]
public void Register_TakesMinimumDate_RegardlessOfOrder_AndNeverReportsUpgrades()
{
var later = _fx.Reconciler.Register(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
var earlier = _fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
var latest = _fx.Reconciler.Register(Media.Movie(_otherItem, _fx.Now.AddDays(1), tmdb: "603"));
Assert.Equal(ReconcileAction.Registered, later.Action);
Assert.Equal(ReconcileAction.FirstAddedLowered, earlier.Action);
Assert.Equal(ReconcileAction.Unchanged, latest.Action);
var entry = _fx.SingleEntry();
Assert.Equal(OriginalDate, entry.FirstAdded);
Assert.Equal(3, entry.LiveItemIds.Count);
}
[Fact]
public void Sync_DetachesItemsThatNoLongerExist()
{
_fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
_fx.Reconciler.Register(Media.Movie(_newItem, OriginalDate, tmdb: "604"));
var unseen = _fx.Reconciler.GetUnseenLiveItemIds(new[] { _oldItem }.ToHashSet());
Assert.Equal([_newItem], unseen);
Assert.Equal(1, _fx.Reconciler.RemoveStaleItems(unseen));
var removed = Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:604"]));
Assert.Empty(removed.LiveItemIds);
Assert.Equal(_fx.Now, removed.RemovedAt);
}
[Fact]
public void ExternalDate_OnlyLowersFirstAdded()
{
var movie = Media.Movie(_oldItem, _fx.Now, tmdb: "603");
_fx.Reconciler.Register(movie);
Assert.False(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, _fx.Now.AddDays(1)));
Assert.True(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, OriginalDate));
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
}
private Task<ReconcileResult> SeenAsync(MediaIdentity identity)
=> _fx.Processor.ProcessSeenAsync(identity, CancellationToken.None);
}
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
internal sealed class ManualTimeProvider : TimeProvider
{
public ManualTimeProvider(DateTimeOffset start)
{
Now = start;
}
public DateTimeOffset Now { get; set; }
public override DateTimeOffset GetUtcNow() => Now;
public void Advance(TimeSpan by) => Now += by;
}
internal sealed class RecordingDateWriter : IItemDateWriter
{
public List<(Guid ItemId, DateTime Date)> Writes { get; } = [];
public bool ItemExists { get; set; } = true;
public Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
{
if (!ItemExists)
{
return Task.FromResult(false);
}
Writes.Add((itemId, dateCreated));
return Task.FromResult(true);
}
}
/// <summary>
/// Wires the core classes against a temporary ledger file and a manual clock.
/// </summary>
internal sealed class GuardFixture : IDisposable
{
public static readonly DateTimeOffset Start = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
public GuardFixture(GuardOptions? options = null)
{
DirectoryPath = Path.Combine(Path.GetTempPath(), "rag-tests", Guid.NewGuid().ToString("N"));
Time = new ManualTimeProvider(Start);
Options = options ?? GuardOptions.Default;
Store = CreateStore();
Reconciler = new Reconciler(Store, () => Options, Time, NullLogger<Reconciler>.Instance);
Writer = new RecordingDateWriter();
Corrections = new CorrectionLog();
Processor = new GuardProcessor(Reconciler, Writer, Corrections, () => Options, Time, NullLogger<GuardProcessor>.Instance);
}
public string DirectoryPath { get; }
public string LedgerPath => Path.Combine(DirectoryPath, LedgerStore.DefaultFileName);
public ManualTimeProvider Time { get; }
public GuardOptions Options { get; set; }
public LedgerStore Store { get; }
public Reconciler Reconciler { get; }
public RecordingDateWriter Writer { get; }
public CorrectionLog Corrections { get; }
public GuardProcessor Processor { get; }
public DateTime Now => Time.GetUtcNow().UtcDateTime;
/// <summary>
/// Creates another store on the same file. The save delay is long, so tests control writes via Flush.
/// </summary>
public LedgerStore CreateStore(TimeSpan? saveDelay = null)
=> new(LedgerPath, NullLogger<LedgerStore>.Instance, Time, saveDelay ?? TimeSpan.FromHours(1));
public LedgerEntry SingleEntry()
{
var entries = Store.GetEntries();
Assert.Single(entries);
return entries[0];
}
public void Dispose()
{
Store.Dispose();
try
{
Directory.Delete(DirectoryPath, recursive: true);
}
catch (DirectoryNotFoundException)
{
}
}
}
internal static class Media
{
public static MediaIdentity Movie(
Guid itemId,
DateTime dateCreated,
string? tmdb = null,
string? imdb = null,
string? tvdb = null,
string name = "The Matrix (1999)")
=> new(itemId, MediaKind.Movie, dateCreated, MediaKeys.ForMovie(tmdb, imdb, tvdb), name);
public static MediaIdentity Episode(
Guid itemId,
DateTime dateCreated,
Guid seriesId,
int season,
int episode,
int? episodeEnd = null,
string? seriesTvdb = null,
string name = "Andor S01E01")
=> new(
itemId,
MediaKind.Episode,
dateCreated,
MediaKeys.ForEpisode(MediaKeys.EpisodePart(season, episode, episodeEnd)!, seriesTvdb, null, null, seriesId),
name);
}
+4
View File
@@ -0,0 +1,4 @@
<Solution>
<Project Path="Jellyfin.Plugin.RecentlyAddedGuard/Jellyfin.Plugin.RecentlyAddedGuard.csproj" />
<Project Path="Jellyfin.Plugin.RecentlyAddedGuard.Tests/Jellyfin.Plugin.RecentlyAddedGuard.Tests.csproj" />
</Solution>
@@ -0,0 +1,76 @@
using System.Net.Mime;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using Jellyfin.Plugin.RecentlyAddedGuard.Tasks;
using MediaBrowser.Common.Api;
using MediaBrowser.Model.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Api;
/// <summary>
/// Status and actions for the configuration page. Jellyfin adds plugin assemblies as MVC application parts,
/// so this controller is discovered automatically.
/// </summary>
[ApiController]
[Route("RecentlyAddedGuard")]
[Authorize(Policy = Policies.RequiresElevation)]
[Produces(MediaTypeNames.Application.Json)]
public class GuardController : ControllerBase
{
private readonly LedgerStore _store;
private readonly CorrectionLog _corrections;
private readonly ITaskManager _taskManager;
/// <summary>
/// Initializes a new instance of the <see cref="GuardController"/> class.
/// </summary>
/// <param name="store">The ledger.</param>
/// <param name="corrections">The correction log.</param>
/// <param name="taskManager">The task manager.</param>
public GuardController(LedgerStore store, CorrectionLog corrections, ITaskManager taskManager)
{
_store = store;
_corrections = corrections;
_taskManager = taskManager;
}
/// <summary>
/// Gets ledger statistics and the most recent corrections.
/// </summary>
/// <response code="200">The status.</response>
/// <returns>The status.</returns>
[HttpGet("Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<GuardStatus> GetStatus()
{
var statistics = _store.GetStatistics();
return new GuardStatus
{
LedgerEntries = statistics.Entries,
LiveEntries = statistics.LiveEntries,
RemovedEntries = statistics.RemovedEntries,
CorrectionsSinceStart = _corrections.CorrectionsSinceStart,
DryRunDetectionsSinceStart = _corrections.DryRunDetectionsSinceStart,
SeedCompleted = _store.SeedCompleted,
LastSyncUtc = _store.LastSyncUtc,
DryRun = Plugin.CurrentConfiguration.DryRun,
RecentCorrections = _corrections.GetRecent()
};
}
/// <summary>
/// Queues the scheduled task "Ledger synchronisieren".
/// </summary>
/// <response code="204">The task was queued (or is already running).</response>
/// <returns>No content.</returns>
[HttpPost("Sync")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult StartSync()
{
_taskManager.QueueIfNotRunning<SyncLedgerTask>();
return NoContent();
}
}
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Api;
/// <summary>
/// Status shown on the configuration page.
/// </summary>
public sealed class GuardStatus
{
/// <summary>
/// Gets the number of ledger entries.
/// </summary>
public int LedgerEntries { get; init; }
/// <summary>
/// Gets the number of entries with at least one live item.
/// </summary>
public int LiveEntries { get; init; }
/// <summary>
/// Gets the number of entries whose items are gone (within the grace period).
/// </summary>
public int RemovedEntries { get; init; }
/// <summary>
/// Gets the number of corrections since the server started.
/// </summary>
public long CorrectionsSinceStart { get; init; }
/// <summary>
/// Gets the number of upgrades detected in dry-run mode since the server started.
/// </summary>
public long DryRunDetectionsSinceStart { get; init; }
/// <summary>
/// Gets a value indicating whether the initial seed has completed.
/// </summary>
public bool SeedCompleted { get; init; }
/// <summary>
/// Gets the time of the last seed or synchronization (UTC).
/// </summary>
public DateTime? LastSyncUtc { get; init; }
/// <summary>
/// Gets a value indicating whether dry-run mode is active.
/// </summary>
public bool DryRun { get; init; }
/// <summary>
/// Gets the most recent corrections, newest first.
/// </summary>
public IReadOnlyList<CorrectionRecord> RecentCorrections { get; init; } = [];
}
@@ -0,0 +1,41 @@
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Configuration;
/// <summary>
/// The plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the number of days within which a returning medium counts as an upgrade.
/// </summary>
public int GraceDays { get; set; } = 14;
/// <summary>
/// Gets or sets a value indicating whether movies are protected.
/// </summary>
public bool EnableMovies { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether episodes are protected.
/// </summary>
public bool EnableEpisodes { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether upgrades are only logged, not corrected.
/// </summary>
public bool DryRun { get; set; }
/// <summary>
/// Gets or sets the tolerance in seconds for comparing dates.
/// </summary>
public int ToleranceSeconds { get; set; } = 60;
/// <summary>
/// Maps the configuration to the options of the core logic.
/// </summary>
/// <returns>The options.</returns>
public GuardOptions ToGuardOptions() => new(GraceDays, ToleranceSeconds, DryRun);
}
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Recently Added Guard</title>
</head>
<body>
<div id="RecentlyAddedGuardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
<style>
#RecentlyAddedGuardConfigPage .ragStats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11em, 1fr)); gap: 0.75em; margin: 1em 0; }
#RecentlyAddedGuardConfigPage .ragStat { padding: 0.75em 1em; border-radius: 0.3em; background: rgba(128, 128, 128, 0.12); }
#RecentlyAddedGuardConfigPage .ragStatValue { font-size: 1.6em; font-weight: 600; }
#RecentlyAddedGuardConfigPage .ragStatLabel { opacity: 0.75; font-size: 0.9em; }
#RecentlyAddedGuardConfigPage .ragTableWrap { overflow-x: auto; }
#RecentlyAddedGuardConfigPage table { width: 100%; border-collapse: collapse; }
#RecentlyAddedGuardConfigPage th, #RecentlyAddedGuardConfigPage td { text-align: left; padding: 0.45em 0.6em; border-bottom: 1px solid rgba(128, 128, 128, 0.25); white-space: nowrap; }
#RecentlyAddedGuardConfigPage td.ragName { white-space: normal; }
#RecentlyAddedGuardConfigPage .ragHint { opacity: 0.75; }
</style>
<div data-role="content">
<div class="content-primary">
<h2 class="sectionTitle">Recently Added Guard</h2>
<p class="ragHint">
Bewahrt das erste Hinzufügedatum jedes Films und jeder Episode. Qualitäts-Upgrades aus Radarr und Sonarr
behalten so ihr ursprüngliches Datum und erscheinen nicht erneut unter „Zuletzt hinzugefügt“.
</p>
<form id="RecentlyAddedGuardConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RagGraceDays">Frist in Tagen</label>
<input id="RagGraceDays" name="GraceDays" type="number" is="emby-input" min="0" max="3650" step="1" required />
<div class="fieldDescription">
Taucht ein gelöschter Film oder eine gelöschte Episode innerhalb dieser Frist wieder auf, gilt das als Upgrade und
das alte Datum wird wiederhergestellt. Nach Ablauf gilt das Medium als neu. Standard: 14.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RagToleranceSeconds">Toleranz in Sekunden</label>
<input id="RagToleranceSeconds" name="ToleranceSeconds" type="number" is="emby-input" min="0" max="86400" step="1" required />
<div class="fieldDescription">Kleinere Abweichungen des Datums werden ignoriert. Standard: 60.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="RagEnableMovies" name="EnableMovies" type="checkbox" is="emby-checkbox" />
<span>Filme schützen</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="RagEnableEpisodes" name="EnableEpisodes" type="checkbox" is="emby-checkbox" />
<span>Episoden schützen</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="RagDryRun" name="DryRun" type="checkbox" is="emby-checkbox" />
<span>Testmodus (DryRun)</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Upgrades nur protokollieren, kein Datum ändern.</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Speichern</span>
</button>
</div>
</form>
<div class="verticalSection" style="margin-top: 2.5em;">
<h2 class="sectionTitle">Status</h2>
<div class="ragStats">
<div class="ragStat"><div id="RagLedgerEntries" class="ragStatValue">–</div><div class="ragStatLabel">Ledger-Einträge</div></div>
<div class="ragStat"><div id="RagLiveEntries" class="ragStatValue">–</div><div class="ragStatLabel">davon mit Items</div></div>
<div class="ragStat"><div id="RagRemovedEntries" class="ragStatValue">–</div><div class="ragStatLabel">in der Frist</div></div>
<div class="ragStat"><div id="RagCorrections" class="ragStatValue">–</div><div class="ragStatLabel">Korrekturen seit Start</div></div>
</div>
<p id="RagSyncInfo" class="ragHint"></p>
<button is="emby-button" type="button" id="RagSyncButton" class="raised block emby-button">
<span>Ledger synchronisieren</span>
</button>
<h3 style="margin-top: 2em;">Letzte Korrekturen</h3>
<div class="ragTableWrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Altes Datum</th>
<th>Neues Datum</th>
<th>Zeitpunkt</th>
</tr>
</thead>
<tbody id="RagCorrectionRows">
<tr><td colspan="4" class="ragHint">Lädt …</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script type="text/javascript">
(function () {
var pluginId = '6fdfedef-adde-4ac3-a873-62a057c95574';
var page = document.querySelector('#RecentlyAddedGuardConfigPage');
var form = page.querySelector('#RecentlyAddedGuardConfigForm');
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(value) {
if (!value) {
return '–';
}
var date = new Date(value);
return isNaN(date.getTime()) ? escapeHtml(value) : date.toLocaleString('de-DE');
}
function loadConfiguration() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
page.querySelector('#RagGraceDays').value = config.GraceDays;
page.querySelector('#RagToleranceSeconds').value = config.ToleranceSeconds;
page.querySelector('#RagEnableMovies').checked = config.EnableMovies;
page.querySelector('#RagEnableEpisodes').checked = config.EnableEpisodes;
page.querySelector('#RagDryRun').checked = config.DryRun;
Dashboard.hideLoadingMsg();
});
}
function renderStatus(status) {
page.querySelector('#RagLedgerEntries').textContent = status.LedgerEntries;
page.querySelector('#RagLiveEntries').textContent = status.LiveEntries;
page.querySelector('#RagRemovedEntries').textContent = status.RemovedEntries;
page.querySelector('#RagCorrections').textContent = status.DryRun
? status.CorrectionsSinceStart + ' (+' + status.DryRunDetectionsSinceStart + ' DryRun)'
: status.CorrectionsSinceStart;
var info = status.SeedCompleted
? 'Letzte Synchronisation: ' + formatDate(status.LastSyncUtc) + '.'
: 'Das Ledger wird gerade zum ersten Mal befüllt (Seed).';
if (status.DryRun) {
info += ' Testmodus aktiv: Upgrades werden nur protokolliert.';
}
page.querySelector('#RagSyncInfo').textContent = info;
var rows = (status.RecentCorrections || []).map(function (c) {
return '<tr>'
+ '<td class="ragName">' + escapeHtml(c.Name) + (c.DryRun ? ' <span class="ragHint">(DryRun)</span>' : '') + '</td>'
+ '<td>' + formatDate(c.PreviousDate) + '</td>'
+ '<td>' + formatDate(c.RestoredDate) + '</td>'
+ '<td>' + formatDate(c.CorrectedAt) + '</td>'
+ '</tr>';
});
page.querySelector('#RagCorrectionRows').innerHTML = rows.length
? rows.join('')
: '<tr><td colspan="4" class="ragHint">Seit dem Start wurden keine Upgrades korrigiert.</td></tr>';
}
function loadStatus() {
return ApiClient.getJSON(ApiClient.getUrl('RecentlyAddedGuard/Status')).then(renderStatus, function () {
page.querySelector('#RagSyncInfo').textContent = 'Status konnte nicht geladen werden.';
});
}
page.addEventListener('pageshow', function () {
loadConfiguration();
loadStatus();
});
form.addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
config.GraceDays = parseInt(page.querySelector('#RagGraceDays').value, 10) || 0;
config.ToleranceSeconds = parseInt(page.querySelector('#RagToleranceSeconds').value, 10) || 0;
config.EnableMovies = page.querySelector('#RagEnableMovies').checked;
config.EnableEpisodes = page.querySelector('#RagEnableEpisodes').checked;
config.DryRun = page.querySelector('#RagDryRun').checked;
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
loadStatus();
});
});
return false;
});
page.querySelector('#RagSyncButton').addEventListener('click', function () {
var button = this;
button.disabled = true;
ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl('RecentlyAddedGuard/Sync') }).then(function () {
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation gestartet. Fortschritt unter Geplante Aufgaben.';
setTimeout(loadStatus, 5000);
}, function () {
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation konnte nicht gestartet werden.';
}).then(function () {
button.disabled = false;
});
});
})();
</script>
</div>
</body>
</html>
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// One restored (or, in dry-run mode, detected) upgrade.
/// </summary>
/// <param name="ItemId">The corrected item.</param>
/// <param name="Name">The display name.</param>
/// <param name="PreviousDate">The "date added" the upgrade brought along (UTC).</param>
/// <param name="RestoredDate">The first "date added" from the ledger (UTC).</param>
/// <param name="CorrectedAt">When the correction happened (UTC).</param>
/// <param name="DryRun"><c>true</c> if only detected, not applied.</param>
public sealed record CorrectionRecord(
Guid ItemId,
string Name,
DateTime PreviousDate,
DateTime RestoredDate,
DateTime CorrectedAt,
bool DryRun);
/// <summary>
/// In-memory ring buffer of the most recent corrections, for the configuration page.
/// </summary>
public sealed class CorrectionLog
{
/// <summary>
/// The default number of kept corrections.
/// </summary>
public const int DefaultCapacity = 100;
private readonly Lock _sync = new();
private readonly List<CorrectionRecord> _records;
private readonly int _capacity;
private long _corrections;
private long _dryRunDetections;
/// <summary>
/// Initializes a new instance of the <see cref="CorrectionLog"/> class.
/// </summary>
/// <param name="capacity">The number of kept corrections.</param>
public CorrectionLog(int capacity = DefaultCapacity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
_capacity = capacity;
_records = new List<CorrectionRecord>(capacity);
}
/// <summary>
/// Gets the number of applied corrections since the server started.
/// </summary>
public long CorrectionsSinceStart => Interlocked.Read(ref _corrections);
/// <summary>
/// Gets the number of upgrades detected in dry-run mode since the server started.
/// </summary>
public long DryRunDetectionsSinceStart => Interlocked.Read(ref _dryRunDetections);
/// <summary>
/// Records a correction, dropping the oldest one when full. Restoring the same date of the same item again
/// (Jellyfin saved the item with its new date once more, e.g. from a metadata refresh that was already running)
/// replaces the earlier record and is not counted twice.
/// </summary>
/// <param name="record">The correction.</param>
/// <returns><c>true</c> for a new correction, <c>false</c> for a repeated one.</returns>
public bool Add(CorrectionRecord record)
{
ArgumentNullException.ThrowIfNull(record);
lock (_sync)
{
var existing = _records.FindIndex(r => r.ItemId == record.ItemId
&& r.RestoredDate == record.RestoredDate
&& r.DryRun == record.DryRun);
if (existing >= 0)
{
_records.RemoveAt(existing);
_records.Add(record);
return false;
}
if (_records.Count == _capacity)
{
_records.RemoveAt(0);
}
_records.Add(record);
}
if (record.DryRun)
{
Interlocked.Increment(ref _dryRunDetections);
}
else
{
Interlocked.Increment(ref _corrections);
}
return true;
}
/// <summary>
/// Returns the kept corrections, newest first.
/// </summary>
/// <returns>The corrections.</returns>
public IReadOnlyList<CorrectionRecord> GetRecent()
{
lock (_sync)
{
return _records.AsEnumerable().Reverse().ToList();
}
}
}
@@ -0,0 +1,27 @@
using System;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// The settings the core logic depends on, decoupled from the Jellyfin plugin configuration.
/// </summary>
/// <param name="GraceDays">Days within which a returning medium counts as an upgrade.</param>
/// <param name="ToleranceSeconds">Date differences up to this many seconds are ignored.</param>
/// <param name="DryRun">Only log detected upgrades, never change items.</param>
public sealed record GuardOptions(int GraceDays, int ToleranceSeconds, bool DryRun)
{
/// <summary>
/// Gets the defaults (14 days, 60 seconds, no dry run).
/// </summary>
public static GuardOptions Default { get; } = new(14, 60, false);
/// <summary>
/// Gets the grace period.
/// </summary>
public TimeSpan GracePeriod => TimeSpan.FromDays(Math.Max(0, GraceDays));
/// <summary>
/// Gets the tolerance for date comparisons.
/// </summary>
public TimeSpan Tolerance => TimeSpan.FromSeconds(Math.Max(0, ToleranceSeconds));
}
@@ -0,0 +1,137 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Turns reconciler decisions into actions: restores the date of upgrades (or only logs them in dry-run mode)
/// and records corrections.
/// </summary>
public sealed class GuardProcessor
{
private readonly Reconciler _reconciler;
private readonly IItemDateWriter _writer;
private readonly CorrectionLog _corrections;
private readonly Func<GuardOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GuardProcessor> _logger;
private readonly ConcurrentDictionary<Guid, DateTime> _dryRunReported = new();
/// <summary>
/// Initializes a new instance of the <see cref="GuardProcessor"/> class.
/// </summary>
/// <param name="reconciler">The reconciler.</param>
/// <param name="writer">Writes item dates.</param>
/// <param name="corrections">The correction log.</param>
/// <param name="options">Provides the current options.</param>
/// <param name="timeProvider">The clock.</param>
/// <param name="logger">The logger.</param>
public GuardProcessor(
Reconciler reconciler,
IItemDateWriter writer,
CorrectionLog corrections,
Func<GuardOptions> options,
TimeProvider timeProvider,
ILogger<GuardProcessor> logger)
{
_reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_corrections = corrections ?? throw new ArgumentNullException(nameof(corrections));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Handles an added or updated item.
/// </summary>
/// <param name="identity">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The reconciler's outcome.</returns>
public async Task<ReconcileResult> ProcessSeenAsync(MediaIdentity identity, CancellationToken cancellationToken)
{
var result = _reconciler.OnItemSeen(identity);
switch (result.Action)
{
case ReconcileAction.UpgradeDetected:
await RestoreDateAsync(result, cancellationToken).ConfigureAwait(false);
break;
case ReconcileAction.ReturnedAfterGrace:
_logger.LogInformation(
"{Name} ist nach Ablauf der Frist zurückgekehrt und gilt als neu (DateAdded {Date:u})",
identity.DisplayName,
result.ItemDate);
break;
case ReconcileAction.Registered:
_logger.LogDebug("Neu registriert: {Name} (DateAdded {Date:u})", identity.DisplayName, result.ItemDate);
break;
case ReconcileAction.FirstAddedLowered:
_logger.LogDebug("Erstes Hinzufügedatum von {Name} auf {Date:u} vorverlegt", identity.DisplayName, result.ItemDate);
break;
default:
break;
}
return result;
}
/// <summary>
/// Handles a removed item.
/// </summary>
/// <param name="itemId">The removed item.</param>
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
/// <returns><c>true</c> if a ledger entry was affected.</returns>
public bool ProcessRemoved(Guid itemId, IReadOnlyList<string> keys) => _reconciler.OnItemRemoved(itemId, keys);
private async Task RestoreDateAsync(ReconcileResult result, CancellationToken cancellationToken)
{
var identity = result.Identity;
if (_options().DryRun)
{
// Every metadata refresh of the item reports the upgrade again; log it once per date.
if (_dryRunReported.TryGetValue(identity.ItemId, out var reported) && reported == result.ItemDate)
{
return;
}
_dryRunReported[identity.ItemId] = result.ItemDate;
_logger.LogInformation(
"[DryRun] Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u} (nicht geändert)",
identity.DisplayName,
result.ItemDate,
result.FirstAdded);
_corrections.Add(new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: true));
return;
}
if (!await _writer.TrySetDateCreatedAsync(identity.ItemId, result.FirstAdded, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug("Item {ItemId} existiert nicht mehr, keine Korrektur", identity.ItemId);
return;
}
_dryRunReported.TryRemove(identity.ItemId, out _);
var record = new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: false);
if (_corrections.Add(record))
{
_logger.LogInformation(
"Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u}",
identity.DisplayName,
result.ItemDate,
result.FirstAdded);
}
else
{
_logger.LogDebug(
"DateAdded von {Name} wurde von Jellyfin erneut überschrieben und wieder auf {Original:u} gesetzt",
identity.DisplayName,
result.FirstAdded);
}
}
private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime;
}
@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// An external source for the first "date added" of a medium, e.g. the import history of Radarr or Sonarr.
/// </summary>
/// <remarks>
/// Extension point for v2: implementations registered in the DI container are consulted by the
/// ledger synchronization, and earlier dates lower the ledger via <see cref="Reconciler.ApplyExternalFirstAdded"/>.
/// v1 ships no implementation.
/// </remarks>
public interface IExternalDateSource
{
/// <summary>
/// Gets the name of the source for logs.
/// </summary>
string Name { get; }
/// <summary>
/// Looks up when the medium was first imported.
/// </summary>
/// <param name="identity">The medium.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The first import date (UTC), or <c>null</c> if unknown.</returns>
Task<DateTime?> GetFirstAddedAsync(MediaIdentity identity, CancellationToken cancellationToken);
}
@@ -0,0 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Writes the "date added" of a library item.
/// </summary>
public interface IItemDateWriter
{
/// <summary>
/// Sets the item's "date added" and saves it.
/// </summary>
/// <param name="itemId">The item.</param>
/// <param name="dateCreated">The new "date added" (UTC).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>false</c> if the item no longer exists.</returns>
Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken);
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Maps Jellyfin items to <see cref="MediaIdentity"/> snapshots for the core logic.
/// </summary>
public sealed class IdentityResolver
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<IdentityResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="IdentityResolver"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
public IdentityResolver(ILibraryManager libraryManager, ILogger<IdentityResolver> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>
/// Checks whether the item is a real movie or episode: no missing (virtual) episode, no extra.
/// </summary>
/// <param name="item">The item.</param>
/// <returns><c>true</c> if the plugin tracks items of this kind.</returns>
public static bool IsTracked(BaseItem item)
=> item is Movie or Episode && !item.IsVirtualItem && item.ExtraType is null;
/// <summary>
/// Resolves the identity of a tracked item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="seriesLookup">Optional cached series lookup (used by the synchronization).</param>
/// <returns>The identity (its keys may be empty if provider ids are still missing),
/// or <c>null</c> if the item is not tracked or its kind is disabled.</returns>
public MediaIdentity? Resolve(BaseItem item, Func<Guid, Series?>? seriesLookup = null)
{
ArgumentNullException.ThrowIfNull(item);
if (!IsTracked(item))
{
return null;
}
var configuration = Plugin.CurrentConfiguration;
switch (item)
{
case Movie movie when configuration.EnableMovies:
return new MediaIdentity(movie.Id, MediaKind.Movie, movie.DateCreated, MovieKeys(movie), MovieName(movie));
case Episode episode when configuration.EnableEpisodes:
var seriesId = FindSeriesId(episode);
var series = FindSeries(seriesId, seriesLookup);
return new MediaIdentity(
episode.Id,
MediaKind.Episode,
episode.DateCreated,
EpisodeKeys(episode, seriesId, series),
EpisodeName(episode, series));
default:
return null;
}
}
/// <summary>
/// Resolves the keys of a removed item. The provider ids are still attached to the object,
/// but the series may already be gone, so every lookup is defensive.
/// </summary>
/// <param name="item">The removed item.</param>
/// <returns>The keys; empty if they cannot be determined.</returns>
public IReadOnlyList<string> ResolveKeysForRemoval(BaseItem item)
{
ArgumentNullException.ThrowIfNull(item);
switch (item)
{
case Movie movie:
return MovieKeys(movie);
case Episode episode:
var seriesId = FindSeriesId(episode);
return EpisodeKeys(episode, seriesId, FindSeries(seriesId, null));
default:
return [];
}
}
private static IReadOnlyList<string> MovieKeys(Movie movie)
=> MediaKeys.ForMovie(
movie.GetProviderId(MetadataProvider.Tmdb),
movie.GetProviderId(MetadataProvider.Imdb),
movie.GetProviderId(MetadataProvider.Tvdb));
private static IReadOnlyList<string> EpisodeKeys(Episode episode, Guid seriesId, Series? series)
{
var part = MediaKeys.EpisodePart(episode.ParentIndexNumber, episode.IndexNumber, episode.IndexNumberEnd);
if (part is null)
{
return [];
}
return MediaKeys.ForEpisode(
part,
series?.GetProviderId(MetadataProvider.Tvdb),
series?.GetProviderId(MetadataProvider.Tmdb),
series?.GetProviderId(MetadataProvider.Imdb),
seriesId);
}
private static string MovieName(Movie movie)
{
var name = string.IsNullOrWhiteSpace(movie.Name) ? movie.Id.ToString("N", CultureInfo.InvariantCulture) : movie.Name;
return movie.ProductionYear is { } year
? string.Create(CultureInfo.InvariantCulture, $"{name} ({year})")
: name;
}
private static string EpisodeName(Episode episode, Series? series)
{
var seriesName = episode.SeriesName ?? series?.Name ?? "?";
var number = string.Create(CultureInfo.InvariantCulture, $"S{episode.ParentIndexNumber ?? 0:00}E{episode.IndexNumber ?? 0:00}");
if (episode.IndexNumberEnd is { } end && end > (episode.IndexNumber ?? 0))
{
number += string.Create(CultureInfo.InvariantCulture, $"-E{end:00}");
}
return string.IsNullOrWhiteSpace(episode.Name)
? $"{seriesName} {number}"
: $"{seriesName} {number} – {episode.Name}";
}
#pragma warning disable CA1031 // Lookups on half-deleted items may fail in many ways; a missing key is acceptable.
private Guid FindSeriesId(Episode episode)
{
if (episode.SeriesId != Guid.Empty)
{
return episode.SeriesId;
}
try
{
return episode.FindSeriesId();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie von Episode {ItemId} nicht ermittelbar", episode.Id);
return Guid.Empty;
}
}
private Series? FindSeries(Guid seriesId, Func<Guid, Series?>? seriesLookup)
{
if (seriesId == Guid.Empty)
{
return null;
}
try
{
return seriesLookup is not null ? seriesLookup(seriesId) : _libraryManager.GetItemById(seriesId) as Series;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie {SeriesId} nicht auffindbar", seriesId);
return null;
}
}
#pragma warning restore CA1031
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Kind of logical medium tracked by the ledger.
/// </summary>
public enum MediaKind
{
/// <summary>A movie.</summary>
Movie,
/// <summary>A TV episode (or a multi-episode file).</summary>
Episode
}
/// <summary>
/// Jellyfin-independent snapshot of a library item, as consumed by the <see cref="Reconciler"/>.
/// </summary>
/// <param name="ItemId">The Jellyfin item id.</param>
/// <param name="Kind">The kind of medium.</param>
/// <param name="DateCreated">The item's current "date added" (UTC).</param>
/// <param name="Keys">All identity keys of the logical medium; empty if the item cannot be identified yet.</param>
/// <param name="DisplayName">Human readable name for logs and the configuration page.</param>
public sealed record MediaIdentity(
Guid ItemId,
MediaKind Kind,
DateTime DateCreated,
IReadOnlyList<string> Keys,
string DisplayName);
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Builds the alias keys that identify a logical medium independent of its file path.
/// </summary>
/// <remarks>
/// Keys are compared case-insensitively by the ledger, so provider ids are only trimmed.
/// </remarks>
public static class MediaKeys
{
/// <summary>
/// Builds the keys of a movie from its provider ids.
/// </summary>
/// <param name="tmdbId">The TMDb id, if known.</param>
/// <param name="imdbId">The IMDb id, if known.</param>
/// <param name="tvdbId">The TVDb id, if known.</param>
/// <returns>The keys; empty if the movie has no provider id yet.</returns>
public static IReadOnlyList<string> ForMovie(string? tmdbId, string? imdbId, string? tvdbId)
{
var keys = new List<string>(3);
AddIfPresent(keys, "movie:tmdb:", tmdbId, string.Empty);
AddIfPresent(keys, "movie:imdb:", imdbId, string.Empty);
AddIfPresent(keys, "movie:tvdb:", tvdbId, string.Empty);
return keys;
}
/// <summary>
/// Builds the episode part of an episode key, e.g. <c>s1e5</c> or <c>s1e5-6</c> for a double episode.
/// </summary>
/// <param name="seasonNumber">The season number (<c>ParentIndexNumber</c>).</param>
/// <param name="episodeNumber">The episode number (<c>IndexNumber</c>).</param>
/// <param name="episodeNumberEnd">The last episode number of a multi-episode file (<c>IndexNumberEnd</c>).</param>
/// <returns>The episode part, or <c>null</c> if season or episode number is missing.</returns>
public static string? EpisodePart(int? seasonNumber, int? episodeNumber, int? episodeNumberEnd)
{
if (seasonNumber is not { } season || episodeNumber is not { } episode)
{
return null;
}
return episodeNumberEnd is { } end && end > episode
? string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}-{end}")
: string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}");
}
/// <summary>
/// Builds the keys of an episode.
/// </summary>
/// <param name="episodePart">The episode part from <see cref="EpisodePart"/>.</param>
/// <param name="seriesTvdbId">The series' TVDb id, if known.</param>
/// <param name="seriesTmdbId">The series' TMDb id, if known.</param>
/// <param name="seriesImdbId">The series' IMDb id, if known.</param>
/// <param name="seriesId">The Jellyfin id of the series; <see cref="Guid.Empty"/> if unknown.</param>
/// <returns>The keys; contains the series-guid key whenever the series id is known.</returns>
public static IReadOnlyList<string> ForEpisode(
string episodePart,
string? seriesTvdbId,
string? seriesTmdbId,
string? seriesImdbId,
Guid seriesId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(episodePart);
var suffix = ":" + episodePart;
var keys = new List<string>(4);
AddIfPresent(keys, "ep:tvdb:", seriesTvdbId, suffix);
AddIfPresent(keys, "ep:tmdb:", seriesTmdbId, suffix);
AddIfPresent(keys, "ep:imdb:", seriesImdbId, suffix);
if (seriesId != Guid.Empty)
{
keys.Add("ep:series-guid:" + seriesId.ToString("N", CultureInfo.InvariantCulture) + suffix);
}
return keys;
}
private static void AddIfPresent(List<string> keys, string prefix, string? id, string suffix)
{
if (!string.IsNullOrWhiteSpace(id))
{
keys.Add(prefix + id.Trim() + suffix);
}
}
}
@@ -0,0 +1,36 @@
using System;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// What the <see cref="Reconciler"/> concluded for an item.
/// </summary>
public enum ReconcileAction
{
/// <summary>The item has no identity keys (yet) and was skipped.</summary>
Ignored,
/// <summary>A new medium; a ledger entry was created with the item's date.</summary>
Registered,
/// <summary>A known medium whose date matches the ledger.</summary>
Unchanged,
/// <summary>The item is older than the ledger (e.g. a date from an NFO); the ledger took the earlier date.</summary>
FirstAddedLowered,
/// <summary>The medium returned after the grace period; it counts as new and the ledger took its date.</summary>
ReturnedAfterGrace,
/// <summary>The item is newer than the ledger: an upgrade whose date has to be restored.</summary>
UpgradeDetected
}
/// <summary>
/// The outcome of reconciling one item with the ledger.
/// </summary>
/// <param name="Action">The conclusion.</param>
/// <param name="Identity">The item.</param>
/// <param name="ItemDate">The item's "date added" as evaluated (UTC).</param>
/// <param name="FirstAdded">The ledger's first "date added" after reconciling (UTC).</param>
public sealed record ReconcileResult(ReconcileAction Action, MediaIdentity Identity, DateTime ItemDate, DateTime FirstAdded);
@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Matches items against the ledger and decides whether an item is new, known or an upgrade.
/// </summary>
/// <remarks>
/// Pure logic without Jellyfin types: it only updates the ledger and reports what it found.
/// Changing items is up to the caller (see <see cref="GuardProcessor"/>).
/// </remarks>
public sealed class Reconciler
{
private readonly LedgerStore _store;
private readonly Func<GuardOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<Reconciler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="Reconciler"/> class.
/// </summary>
/// <param name="store">The ledger.</param>
/// <param name="options">Provides the current options.</param>
/// <param name="timeProvider">The clock.</param>
/// <param name="logger">The logger.</param>
public Reconciler(LedgerStore store, Func<GuardOptions> options, TimeProvider timeProvider, ILogger<Reconciler> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Handles an added or updated item: registers it and detects upgrades.
/// </summary>
/// <param name="identity">The item.</param>
/// <returns>The outcome; <see cref="ReconcileAction.UpgradeDetected"/> asks the caller to restore <see cref="ReconcileResult.FirstAdded"/>.</returns>
public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true);
/// <summary>
/// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported.
/// </summary>
/// <param name="identity">The item.</param>
/// <returns>The outcome.</returns>
public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false);
/// <summary>
/// Handles a removed item. The entry is found by item id, or by key if the item was never registered.
/// </summary>
/// <param name="itemId">The removed item.</param>
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
/// <returns><c>true</c> if a ledger entry was affected.</returns>
public bool OnItemRemoved(Guid itemId, IReadOnlyList<string> keys)
{
ArgumentNullException.ThrowIfNull(keys);
var now = UtcNow();
lock (_store.SyncRoot)
{
var entry = _store.FindByItemId(itemId);
if (entry is not null)
{
_store.DetachItem(entry, itemId);
}
else
{
var matches = keys.Count > 0 ? _store.FindByKeys(keys) : [];
if (matches.Count == 0)
{
return false;
}
entry = matches[0];
}
if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null)
{
entry.RemovedAt = now;
_logger.LogDebug("Letztes Item von {Name} entfernt, Frist läuft ab {RemovedAt:u}", entry.DisplayName, now);
}
_store.MarkDirty();
return true;
}
}
/// <summary>
/// Lists live item ids the ledger knows but a synchronization did not see.
/// </summary>
/// <param name="seenItemIds">The items seen by the synchronization.</param>
/// <returns>Candidates for <see cref="RemoveStaleItems"/>; the caller checks whether they still exist.</returns>
public IReadOnlyList<Guid> GetUnseenLiveItemIds(IReadOnlySet<Guid> seenItemIds)
{
ArgumentNullException.ThrowIfNull(seenItemIds);
lock (_store.SyncRoot)
{
return _store.GetEntries()
.SelectMany(entry => entry.LiveItemIds)
.Where(itemId => !seenItemIds.Contains(itemId))
.ToList();
}
}
/// <summary>
/// Removes items that no longer exist from their entries and starts the grace period for emptied entries.
/// </summary>
/// <param name="itemIds">The items that no longer exist.</param>
/// <returns>The number of detached items.</returns>
public int RemoveStaleItems(IEnumerable<Guid> itemIds)
{
ArgumentNullException.ThrowIfNull(itemIds);
var now = UtcNow();
var detached = 0;
lock (_store.SyncRoot)
{
foreach (var itemId in itemIds)
{
var entry = _store.FindByItemId(itemId);
if (entry is null)
{
continue;
}
_store.DetachItem(entry, itemId);
detached++;
if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null)
{
entry.RemovedAt = now;
}
}
if (detached > 0)
{
_store.MarkDirty();
}
}
return detached;
}
/// <summary>
/// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway.
/// </summary>
/// <returns>The number of deleted entries.</returns>
public int Prune()
{
var now = UtcNow();
var retention = _options().GracePeriod + TimeSpan.FromDays(1);
var pruned = 0;
var changed = false;
lock (_store.SyncRoot)
{
foreach (var entry in _store.GetEntries())
{
if (entry.LiveItemIds.Count > 0)
{
continue;
}
if (entry.RemovedAt is not { } removedAt)
{
// Lost its last item without a removal event; start the grace period now.
entry.RemovedAt = now;
changed = true;
}
else if (now - removedAt > retention)
{
_store.Remove(entry);
pruned++;
}
}
if (pruned > 0 || changed)
{
_store.MarkDirty();
}
}
return pruned;
}
/// <summary>
/// Lowers the first "date added" of a known medium from an external source (e.g. the import history
/// of Radarr/Sonarr). Never raises it and never changes items.
/// </summary>
/// <param name="keys">The keys of the medium.</param>
/// <param name="firstAdded">The date reported by the source.</param>
/// <returns><c>true</c> if the ledger was lowered.</returns>
public bool ApplyExternalFirstAdded(IReadOnlyList<string> keys, DateTime firstAdded)
{
ArgumentNullException.ThrowIfNull(keys);
var date = LedgerStore.AsUtc(firstAdded);
lock (_store.SyncRoot)
{
var entry = _store.FindByKeys(keys).MinBy(candidate => candidate.FirstAdded);
if (entry is null || date >= entry.FirstAdded)
{
return false;
}
entry.FirstAdded = date;
_store.MarkDirty();
return true;
}
}
private ReconcileResult Apply(MediaIdentity identity, bool detectUpgrades)
{
ArgumentNullException.ThrowIfNull(identity);
var now = UtcNow();
var itemDate = identity.DateCreated == DateTime.MinValue ? now : LedgerStore.AsUtc(identity.DateCreated);
if (identity.Keys.Count == 0)
{
return new ReconcileResult(ReconcileAction.Ignored, identity, itemDate, itemDate);
}
var options = _options();
lock (_store.SyncRoot)
{
var candidates = _store.FindByKeys(identity.Keys);
ReleaseFromOutdatedEntry(identity.ItemId, candidates);
var entry = candidates.Count switch
{
0 => null,
1 => candidates[0],
_ => Merge(candidates, now, options)
};
if (entry is null)
{
entry = _store.Add(identity.Keys, itemDate, now, identity.DisplayName);
_store.AttachItem(entry, identity.ItemId);
_store.MarkDirty();
return new ReconcileResult(ReconcileAction.Registered, identity, itemDate, entry.FirstAdded);
}
var wasLive = entry.LiveItemIds.Contains(identity.ItemId);
_store.AddKeys(entry, identity.Keys);
_store.AttachItem(entry, identity.ItemId);
entry.LastSeen = now;
entry.DisplayName = identity.DisplayName;
_store.MarkDirty();
if (entry.RemovedAt is { } removedAt && !wasLive && now - removedAt > options.GracePeriod)
{
// Deleted on purpose and fetched again later: a genuinely new addition.
entry.RemovedAt = null;
entry.FirstAdded = itemDate;
return new ReconcileResult(ReconcileAction.ReturnedAfterGrace, identity, itemDate, itemDate);
}
entry.RemovedAt = null;
// Seed and sync take the exact minimum, so the order of items does not matter.
var tolerance = detectUpgrades ? options.Tolerance : TimeSpan.Zero;
if (entry.FirstAdded - itemDate > tolerance)
{
entry.FirstAdded = itemDate;
return new ReconcileResult(ReconcileAction.FirstAddedLowered, identity, itemDate, itemDate);
}
if (detectUpgrades && itemDate - entry.FirstAdded > tolerance)
{
return new ReconcileResult(ReconcileAction.UpgradeDetected, identity, itemDate, entry.FirstAdded);
}
return new ReconcileResult(ReconcileAction.Unchanged, identity, itemDate, entry.FirstAdded);
}
}
/// <summary>
/// Detaches the item from an entry its keys no longer match (e.g. after "Identify").
/// The orphaned entry is dropped if the item was its only one, as the medium lives on under the new keys.
/// </summary>
private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList<LedgerEntry> candidates)
{
var previous = _store.FindByItemId(itemId);
if (previous is null || candidates.Contains(previous))
{
return;
}
_store.DetachItem(previous, itemId);
if (previous.LiveItemIds.Count == 0)
{
_store.Remove(previous);
}
_logger.LogDebug("Item {ItemId} passt nicht mehr zu {Name} und wurde neu zugeordnet", itemId, previous.DisplayName);
}
/// <summary>
/// Merges several entries that turned out to be the same medium. Entries past their grace period
/// only contribute keys, so a medium that returned as new does not inherit an old date.
/// </summary>
private LedgerEntry Merge(IReadOnlyList<LedgerEntry> candidates, DateTime now, GuardOptions options)
{
var active = candidates.Where(entry => !IsExpired(entry, now, options)).ToList();
var pool = active.Count > 0 ? active : candidates;
var target = pool.MinBy(entry => entry.FirstAdded)!;
var latestRemoval = pool.Max(entry => entry.RemovedAt);
foreach (var other in candidates)
{
_store.Absorb(target, other);
}
target.RemovedAt = target.LiveItemIds.Count > 0 ? null : latestRemoval;
_logger.LogDebug("{Count} Ledger-Einträge zu {Name} zusammengeführt", candidates.Count, target.DisplayName);
return target;
}
private static bool IsExpired(LedgerEntry entry, DateTime now, GuardOptions options)
=> entry.LiveItemIds.Count == 0
&& entry.RemovedAt is { } removedAt
&& now - removedAt > options.GracePeriod;
private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime;
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard</RootNamespace>
<AssemblyName>Jellyfin.Plugin.RecentlyAddedGuard</AssemblyName>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<!-- Provided by the Jellyfin server at runtime; only the plugin DLL is shipped. -->
<PackageReference Include="Jellyfin.Controller" Version="12.2.0" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Model" Version="12.2.0" ExcludeAssets="runtime" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Jellyfin.Plugin.RecentlyAddedGuard.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
/// <summary>
/// The first "date added" of one logical medium (a movie or an episode), independent of its files.
/// </summary>
/// <remarks>
/// <see cref="Keys"/> and <see cref="LiveItemIds"/> are indexed by the <see cref="LedgerStore"/>;
/// change them only through the store so the indexes stay consistent.
/// </remarks>
public sealed class LedgerEntry
{
/// <summary>
/// Gets or sets all alias keys of the medium (e.g. <c>movie:tmdb:603</c>).
/// </summary>
#pragma warning disable CA1819 // Plain persistence model; the store owns all mutations.
public string[] Keys { get; set; } = [];
#pragma warning restore CA1819
/// <summary>
/// Gets or sets the protected first "date added" (UTC).
/// </summary>
public DateTime FirstAdded { get; set; }
/// <summary>
/// Gets or sets when an item of this medium was last seen (UTC).
/// </summary>
public DateTime LastSeen { get; set; }
/// <summary>
/// Gets or sets when the last live item of this medium disappeared (UTC); <c>null</c> while items exist.
/// </summary>
public DateTime? RemovedAt { get; set; }
/// <summary>
/// Gets or sets the ids of the Jellyfin items currently representing this medium.
/// </summary>
#pragma warning disable CA2227 // Plain persistence model; the store owns all mutations.
public HashSet<Guid> LiveItemIds { get; set; } = [];
#pragma warning restore CA2227
/// <summary>
/// Gets or sets a display name for logs and the configuration page.
/// </summary>
public string DisplayName { get; set; } = string.Empty;
}
@@ -0,0 +1,9 @@
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
/// <summary>
/// Counters describing the ledger.
/// </summary>
/// <param name="Entries">All entries.</param>
/// <param name="LiveEntries">Entries with at least one live item.</param>
/// <param name="RemovedEntries">Entries whose items are gone (kept for the grace period).</param>
public sealed record LedgerStatistics(int Entries, int LiveEntries, int RemovedEntries);
@@ -0,0 +1,641 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
/// <summary>
/// Thread-safe, persistent ledger with indexes by alias key and by live item id.
/// </summary>
/// <remarks>
/// All state is guarded by <see cref="SyncRoot"/> (re-entrant). Writes are debounced and atomic:
/// the JSON goes to a temporary file first, which then replaces the ledger file.
/// </remarks>
public sealed class LedgerStore : IDisposable
{
/// <summary>
/// The file name of the ledger inside the plugin data folder.
/// </summary>
public const string DefaultFileName = "ledger.json";
private const int SchemaVersion = 1;
private static readonly TimeSpan _defaultSaveDelay = TimeSpan.FromSeconds(5);
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = false };
private readonly Lock _sync = new();
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly List<LedgerEntry> _entries = [];
private readonly Dictionary<string, LedgerEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Guid, LedgerEntry> _byItemId = [];
private readonly ILogger<LedgerStore> _logger;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _saveDelay;
private readonly ITimer _saveTimer;
private bool _seedCompleted;
private DateTime? _lastSyncUtc;
private bool _dirty;
private bool _saveScheduled;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="LedgerStore"/> class.
/// </summary>
/// <param name="filePath">The path of the ledger file.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">The time provider; defaults to the system clock.</param>
/// <param name="saveDelay">How long changes are collected before they are written; defaults to 5 seconds.</param>
public LedgerStore(string filePath, ILogger<LedgerStore> logger, TimeProvider? timeProvider = null, TimeSpan? saveDelay = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentNullException.ThrowIfNull(logger);
FilePath = filePath;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_saveDelay = saveDelay ?? _defaultSaveDelay;
_saveTimer = _timeProvider.CreateTimer(_ => OnSaveTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Gets the path of the ledger file.
/// </summary>
public string FilePath { get; }
/// <summary>
/// Gets a value indicating whether the initial seed has completed.
/// </summary>
public bool SeedCompleted
{
get
{
lock (_sync)
{
return _seedCompleted;
}
}
}
/// <summary>
/// Gets the time of the last completed seed or synchronization (UTC).
/// </summary>
public DateTime? LastSyncUtc
{
get
{
lock (_sync)
{
return _lastSyncUtc;
}
}
}
/// <summary>
/// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls.
/// </summary>
internal Lock SyncRoot => _sync;
/// <summary>
/// Gets counters for the status page.
/// </summary>
/// <returns>The current statistics.</returns>
public LedgerStatistics GetStatistics()
{
lock (_sync)
{
var live = 0;
var removed = 0;
foreach (var entry in _entries)
{
if (entry.LiveItemIds.Count > 0)
{
live++;
}
else if (entry.RemovedAt is not null)
{
removed++;
}
}
return new LedgerStatistics(_entries.Count, live, removed);
}
}
/// <summary>
/// Loads the ledger from disk, replacing the in-memory state. A missing file yields an empty ledger;
/// an unreadable file is moved aside so the seed runs again.
/// </summary>
public void Load()
{
TryDelete(TempPath);
LedgerDocument? document = null;
if (File.Exists(FilePath))
{
try
{
using var stream = File.OpenRead(FilePath);
document = JsonSerializer.Deserialize<LedgerDocument>(stream, _jsonOptions)
?? throw new JsonException("The ledger file is empty.");
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
{
var backupPath = FilePath + ".corrupt-"
+ _timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
TryMove(FilePath, backupPath);
_logger.LogError(ex, "Ledger {Path} ist nicht lesbar und wurde nach {Backup} verschoben, der Seed läuft erneut", FilePath, backupPath);
document = null;
}
}
int count;
lock (_sync)
{
_entries.Clear();
_byKey.Clear();
_byItemId.Clear();
_seedCompleted = document?.SeedCompleted ?? false;
_lastSyncUtc = document?.LastSyncUtc is { } lastSync ? AsUtc(lastSync) : null;
_dirty = false;
foreach (var loaded in document?.Entries ?? [])
{
Restore(loaded);
}
count = _entries.Count;
}
_logger.LogInformation("Ledger geladen: {Count} Einträge, Seed abgeschlossen: {SeedCompleted}", count, document?.SeedCompleted ?? false);
}
/// <summary>
/// Requests a debounced save. Several changes within the save delay result in a single write.
/// </summary>
public void MarkDirty()
{
lock (_sync)
{
_dirty = true;
if (_saveScheduled || _disposed)
{
return;
}
_saveScheduled = true;
}
try
{
_saveTimer.Change(_saveDelay, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Disposed concurrently; the final flush happens in Dispose.
}
}
/// <summary>
/// Writes pending changes immediately.
/// </summary>
public void Flush() => SaveIfDirty();
/// <inheritdoc />
public void Dispose()
{
lock (_sync)
{
if (_disposed)
{
return;
}
}
try
{
Flush();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden: {Path}", FilePath);
}
lock (_sync)
{
_disposed = true;
}
_saveTimer.Dispose();
_writeGate.Dispose();
}
/// <summary>
/// Finds all distinct entries that own at least one of the keys.
/// </summary>
/// <param name="keys">The keys to look up.</param>
/// <returns>The matching entries, in key order.</returns>
internal IReadOnlyList<LedgerEntry> FindByKeys(IEnumerable<string> keys)
{
lock (_sync)
{
List<LedgerEntry>? result = null;
foreach (var key in keys)
{
if (_byKey.TryGetValue(key, out var entry) && (result is null || !result.Contains(entry)))
{
(result ??= []).Add(entry);
}
}
return result ?? [];
}
}
/// <summary>
/// Finds the entry that currently lists the item as live.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <returns>The entry, or <c>null</c>.</returns>
internal LedgerEntry? FindByItemId(Guid itemId)
{
lock (_sync)
{
return _byItemId.GetValueOrDefault(itemId);
}
}
/// <summary>
/// Adds a new entry without live items.
/// </summary>
/// <param name="keys">The keys; keys owned by other entries are skipped.</param>
/// <param name="firstAdded">The first "date added".</param>
/// <param name="now">The current time.</param>
/// <param name="displayName">The display name.</param>
/// <returns>The new entry.</returns>
internal LedgerEntry Add(IEnumerable<string> keys, DateTime firstAdded, DateTime now, string displayName)
{
var entry = new LedgerEntry
{
FirstAdded = AsUtc(firstAdded),
LastSeen = AsUtc(now),
DisplayName = displayName
};
lock (_sync)
{
_entries.Add(entry);
AddKeys(entry, keys);
}
return entry;
}
/// <summary>
/// Adds keys to an entry. Keys that already belong to another entry are skipped,
/// so callers merge all matching entries first.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="keys">The keys.</param>
internal void AddKeys(LedgerEntry entry, IEnumerable<string> keys)
{
lock (_sync)
{
List<string>? added = null;
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key) || _byKey.ContainsKey(key))
{
continue;
}
_byKey[key] = entry;
(added ??= []).Add(key);
}
if (added is not null)
{
entry.Keys = [.. entry.Keys, .. added];
}
}
}
/// <summary>
/// Records the item as a live item of the entry, moving it away from any other entry.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="itemId">The item id.</param>
internal void AttachItem(LedgerEntry entry, Guid itemId)
{
lock (_sync)
{
if (_byItemId.TryGetValue(itemId, out var owner) && !ReferenceEquals(owner, entry))
{
owner.LiveItemIds.Remove(itemId);
}
entry.LiveItemIds.Add(itemId);
_byItemId[itemId] = entry;
}
}
/// <summary>
/// Removes the item from the live items of the entry.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="itemId">The item id.</param>
internal void DetachItem(LedgerEntry entry, Guid itemId)
{
lock (_sync)
{
entry.LiveItemIds.Remove(itemId);
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
{
_byItemId.Remove(itemId);
}
}
}
/// <summary>
/// Removes an entry and its index entries.
/// </summary>
/// <param name="entry">The entry.</param>
internal void Remove(LedgerEntry entry)
{
lock (_sync)
{
if (!_entries.Remove(entry))
{
return;
}
foreach (var key in entry.Keys)
{
if (_byKey.TryGetValue(key, out var owner) && ReferenceEquals(owner, entry))
{
_byKey.Remove(key);
}
}
foreach (var itemId in entry.LiveItemIds)
{
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
{
_byItemId.Remove(itemId);
}
}
}
}
/// <summary>
/// Moves keys and live items of <paramref name="source"/> into <paramref name="target"/> and removes the source.
/// Dates are left to the caller.
/// </summary>
/// <param name="target">The surviving entry.</param>
/// <param name="source">The entry to merge into the target.</param>
internal void Absorb(LedgerEntry target, LedgerEntry source)
{
if (ReferenceEquals(target, source))
{
return;
}
lock (_sync)
{
var keys = source.Keys;
var itemIds = source.LiveItemIds.ToArray();
Remove(source);
AddKeys(target, keys);
foreach (var itemId in itemIds)
{
AttachItem(target, itemId);
}
if (source.LastSeen > target.LastSeen)
{
target.LastSeen = source.LastSeen;
}
}
}
/// <summary>
/// Returns a snapshot of the entry references for iteration outside the lock.
/// </summary>
/// <returns>The entries.</returns>
internal IReadOnlyList<LedgerEntry> GetEntries()
{
lock (_sync)
{
return [.. _entries];
}
}
/// <summary>
/// Records a completed seed or synchronization.
/// </summary>
/// <param name="now">The completion time.</param>
internal void MarkSynced(DateTime now)
{
lock (_sync)
{
_seedCompleted = true;
_lastSyncUtc = AsUtc(now);
}
MarkDirty();
}
/// <summary>
/// Normalizes a timestamp to UTC; unspecified values are taken as UTC.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>The UTC value.</returns>
internal static DateTime AsUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
private string TempPath => FilePath + ".tmp";
private void Restore(LedgerEntry loaded)
{
var keys = (loaded.Keys ?? [])
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (keys.Length == 0)
{
return;
}
var itemIds = loaded.LiveItemIds ?? [];
var firstAdded = AsUtc(loaded.FirstAdded);
var lastSeen = AsUtc(loaded.LastSeen);
DateTime? removedAt = loaded.RemovedAt is { } removed ? AsUtc(removed) : null;
// Overlapping entries can only come from an older or damaged file: merge them, keeping the earliest date.
var existing = keys.Select(key => _byKey.GetValueOrDefault(key)).FirstOrDefault(entry => entry is not null);
if (existing is not null)
{
AddKeys(existing, keys);
foreach (var itemId in itemIds)
{
AttachItem(existing, itemId);
}
if (firstAdded < existing.FirstAdded)
{
existing.FirstAdded = firstAdded;
}
if (lastSeen > existing.LastSeen)
{
existing.LastSeen = lastSeen;
}
existing.RemovedAt = existing.LiveItemIds.Count > 0 ? null : existing.RemovedAt ?? removedAt;
return;
}
var entry = new LedgerEntry
{
FirstAdded = firstAdded,
LastSeen = lastSeen,
RemovedAt = itemIds.Count > 0 ? null : removedAt,
DisplayName = loaded.DisplayName ?? string.Empty
};
_entries.Add(entry);
AddKeys(entry, keys);
foreach (var itemId in itemIds)
{
AttachItem(entry, itemId);
}
}
#pragma warning disable CA1031 // A timer callback must never throw; failures are logged and retried.
private void OnSaveTimer()
{
try
{
SaveIfDirty();
}
catch (ObjectDisposedException)
{
// Shutting down.
}
catch (Exception ex)
{
_logger.LogError(ex, "Ledger konnte nicht gespeichert werden: {Path}", FilePath);
MarkDirty();
}
}
#pragma warning restore CA1031
private void SaveIfDirty()
{
_writeGate.Wait();
try
{
byte[] payload;
lock (_sync)
{
_saveScheduled = false;
if (!_dirty)
{
return;
}
payload = JsonSerializer.SerializeToUtf8Bytes(
new LedgerDocument
{
SchemaVersion = SchemaVersion,
SeedCompleted = _seedCompleted,
LastSyncUtc = _lastSyncUtc,
Entries = _entries
},
_jsonOptions);
_dirty = false;
}
try
{
WriteAtomically(payload);
}
catch
{
lock (_sync)
{
_dirty = true;
}
throw;
}
}
finally
{
_writeGate.Release();
}
}
private void WriteAtomically(byte[] payload)
{
var directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
using (var stream = new FileStream(TempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(payload);
stream.Flush(flushToDisk: true);
}
File.Move(TempPath, FilePath, overwrite: true);
_logger.LogDebug("Ledger gespeichert: {Path} ({Bytes} Bytes)", FilePath, payload.Length);
}
private void TryMove(string source, string destination)
{
try
{
File.Move(source, destination, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Datei {Source} konnte nicht verschoben werden", source);
}
}
private void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Temporäre Datei {Path} konnte nicht gelöscht werden", path);
}
}
/// <summary>
/// The on-disk format of the ledger.
/// </summary>
private sealed class LedgerDocument
{
public int SchemaVersion { get; set; }
public bool SeedCompleted { get; set; }
public DateTime? LastSyncUtc { get; set; }
public List<LedgerEntry> Entries { get; set; } = [];
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.RecentlyAddedGuard.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.RecentlyAddedGuard;
/// <summary>
/// Keeps quality upgrades from Radarr and Sonarr out of "Recently Added".
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// The permanent plugin id. Never change it: it identifies the plugin in the catalog and in installations.
/// </summary>
public const string PluginGuid = "6fdfedef-adde-4ac3-a873-62a057c95574";
private static readonly Guid _pluginId = Guid.Parse(PluginGuid);
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="xmlSerializer">The XML serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the plugin instance; set once Jellyfin has created the plugin.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public override string Name => "Recently Added Guard";
/// <inheritdoc />
public override Guid Id => _pluginId;
/// <inheritdoc />
public override string Description
=> "Hält Qualitäts-Upgrades von Radarr und Sonarr aus „Zuletzt hinzugefügt“ fern, indem es das erste Hinzufügedatum pro Film und Episode bewahrt.";
/// <summary>
/// Gets the current configuration, or the defaults before the plugin is loaded.
/// </summary>
internal static PluginConfiguration CurrentConfiguration => Instance?.Configuration ?? new PluginConfiguration();
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = "RecentlyAddedGuard",
DisplayName = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
};
}
}
@@ -0,0 +1,50 @@
using System;
using System.IO;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using Jellyfin.Plugin.RecentlyAddedGuard.Services;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard;
/// <summary>
/// Registers the plugin services with Jellyfin's DI container.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Runs before the plugin instance exists; factories are evaluated later, when the host starts.
serviceCollection.AddSingleton<CorrectionLog>();
serviceCollection.AddSingleton(sp => new LedgerStore(
Path.Combine(GetDataFolderPath(sp), LedgerStore.DefaultFileName),
sp.GetRequiredService<ILogger<LedgerStore>>()));
serviceCollection.AddSingleton(sp => new Reconciler(
sp.GetRequiredService<LedgerStore>(),
GetOptions,
TimeProvider.System,
sp.GetRequiredService<ILogger<Reconciler>>()));
serviceCollection.AddSingleton<IdentityResolver>();
serviceCollection.AddSingleton<LibraryItemDateWriter>();
serviceCollection.AddSingleton(sp => new GuardProcessor(
sp.GetRequiredService<Reconciler>(),
sp.GetRequiredService<LibraryItemDateWriter>(),
sp.GetRequiredService<CorrectionLog>(),
GetOptions,
TimeProvider.System,
sp.GetRequiredService<ILogger<GuardProcessor>>()));
serviceCollection.AddSingleton<LedgerSynchronizer>();
serviceCollection.AddHostedService<GuardHostedService>();
}
private static GuardOptions GetOptions() => Plugin.CurrentConfiguration.ToGuardOptions();
private static string GetDataFolderPath(IServiceProvider serviceProvider)
=> Plugin.Instance?.DataFolderPath
?? Path.Combine(serviceProvider.GetRequiredService<IApplicationPaths>().PluginsPath, typeof(Plugin).Assembly.GetName().Name!);
}
@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Listens to library events and processes them on a background worker; runs the seed on first start.
/// </summary>
public sealed class GuardHostedService : IHostedService, IDisposable
{
private static readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(2);
private readonly ILibraryManager _libraryManager;
private readonly IdentityResolver _resolver;
private readonly GuardProcessor _processor;
private readonly LibraryItemDateWriter _writer;
private readonly LedgerStore _store;
private readonly LedgerSynchronizer _synchronizer;
private readonly ILogger<GuardHostedService> _logger;
private readonly Channel<GuardEvent> _events = Channel.CreateUnbounded<GuardEvent>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly CancellationTokenSource _stopping = new();
private Task _seed = Task.CompletedTask;
private Task _worker = Task.CompletedTask;
/// <summary>
/// Initializes a new instance of the <see cref="GuardHostedService"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="resolver">The identity resolver.</param>
/// <param name="processor">The processor.</param>
/// <param name="writer">The date writer.</param>
/// <param name="store">The ledger.</param>
/// <param name="synchronizer">The synchronizer.</param>
/// <param name="logger">The logger.</param>
public GuardHostedService(
ILibraryManager libraryManager,
IdentityResolver resolver,
GuardProcessor processor,
LibraryItemDateWriter writer,
LedgerStore store,
LedgerSynchronizer synchronizer,
ILogger<GuardHostedService> logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_processor = processor;
_writer = writer;
_store = store;
_synchronizer = synchronizer;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_store.Load();
_libraryManager.ItemAdded += OnItemAddedOrUpdated;
_libraryManager.ItemUpdated += OnItemAddedOrUpdated;
_libraryManager.ItemRemoved += OnItemRemoved;
if (!_store.SeedCompleted)
{
_seed = Task.Run(() => RunSeedAsync(_stopping.Token), CancellationToken.None);
}
_worker = Task.Run(() => RunWorkerAsync(_stopping.Token), CancellationToken.None);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded -= OnItemAddedOrUpdated;
_libraryManager.ItemUpdated -= OnItemAddedOrUpdated;
_libraryManager.ItemRemoved -= OnItemRemoved;
_events.Writer.TryComplete();
// An unfinished seed starts over on the next start; pending events are still processed.
if (!_seed.IsCompleted)
{
await _stopping.CancelAsync().ConfigureAwait(false);
}
try
{
await Task.WhenAll(_seed, _worker).WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await _stopping.CancelAsync().ConfigureAwait(false);
}
try
{
_store.Flush();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden");
}
}
/// <inheritdoc />
public void Dispose() => _stopping.Dispose();
private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
if (item is null || !IdentityResolver.IsTracked(item) || _writer.IsWriting(item.Id))
{
return;
}
_events.Writer.TryWrite(new GuardEvent(item.Id, null));
}
private void OnItemRemoved(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
if (item is null || !IdentityResolver.IsTracked(item))
{
return;
}
// Keep the object: its provider ids are needed later, the database row is already gone.
_events.Writer.TryWrite(new GuardEvent(item.Id, item));
}
#pragma warning disable CA1031 // Background loops log failures and keep running.
private async Task RunSeedAsync(CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Ledger wurde noch nicht befüllt, Seed läuft im Hintergrund");
await _synchronizer.RunAsync(new Progress<double>(), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Ledger-Seed abgebrochen, er läuft beim nächsten Start erneut");
}
catch (Exception ex)
{
_logger.LogError(ex, "Ledger-Seed fehlgeschlagen; der Scheduled Task \"Ledger synchronisieren\" holt ihn nach");
}
}
private async Task RunWorkerAsync(CancellationToken cancellationToken)
{
try
{
// Events raised during the seed wait in the channel, so they are matched against a complete ledger.
await _seed.ConfigureAwait(false);
var reader = _events.Reader;
var pending = new OrderedDictionary<Guid, GuardEvent>();
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
// A scan raises several events per item within moments; collect the burst, keep the latest per item.
await Task.Delay(_debounceDelay, cancellationToken).ConfigureAwait(false);
while (reader.TryRead(out var guardEvent))
{
pending[guardEvent.ItemId] = guardEvent;
}
foreach (var guardEvent in pending.Values)
{
await ProcessAsync(guardEvent, cancellationToken).ConfigureAwait(false);
}
pending.Clear();
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutting down.
}
catch (Exception ex)
{
_logger.LogError(ex, "Event-Verarbeitung von Recently Added Guard wurde unerwartet beendet");
}
}
private async Task ProcessAsync(GuardEvent guardEvent, CancellationToken cancellationToken)
{
try
{
if (guardEvent.RemovedItem is { } removed)
{
_processor.ProcessRemoved(guardEvent.ItemId, _resolver.ResolveKeysForRemoval(removed));
return;
}
var item = _libraryManager.GetItemById(guardEvent.ItemId);
var identity = item is null ? null : _resolver.Resolve(item);
if (identity is not null)
{
await _processor.ProcessSeenAsync(identity, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Item {ItemId} konnte nicht verarbeitet werden", guardEvent.ItemId);
}
}
#pragma warning restore CA1031
/// <summary>
/// A queued library event. <see cref="RemovedItem"/> is set for removals.
/// </summary>
private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem);
}
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them.
/// </summary>
public sealed class LedgerSynchronizer : IDisposable
{
private const int BatchSize = 500;
private static readonly BaseItemKind[] _trackedKinds = [BaseItemKind.Movie, BaseItemKind.Episode];
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ILibraryManager _libraryManager;
private readonly IdentityResolver _resolver;
private readonly Reconciler _reconciler;
private readonly LedgerStore _store;
private readonly IExternalDateSource[] _externalSources;
private readonly ILogger<LedgerSynchronizer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LedgerSynchronizer"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="resolver">The identity resolver.</param>
/// <param name="reconciler">The reconciler.</param>
/// <param name="store">The ledger.</param>
/// <param name="externalSources">External date sources (none in v1).</param>
/// <param name="logger">The logger.</param>
public LedgerSynchronizer(
ILibraryManager libraryManager,
IdentityResolver resolver,
Reconciler reconciler,
LedgerStore store,
IEnumerable<IExternalDateSource> externalSources,
ILogger<LedgerSynchronizer> logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_reconciler = reconciler;
_store = store;
_externalSources = externalSources.ToArray();
_logger = logger;
}
/// <summary>
/// Registers all movies and episodes, detaches items that no longer exist and prunes old entries.
/// The first run on an empty ledger is the seed. Concurrent calls wait for each other.
/// </summary>
/// <param name="progress">Reports progress from 0 to 100.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that completes when the ledger is synchronized and saved.</returns>
public async Task RunAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(progress);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public void Dispose() => _gate.Dispose();
private async Task RunCoreAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var isSeed = !_store.SeedCompleted;
var stopwatch = Stopwatch.StartNew();
progress.Report(0);
// Ids first, then the items in batches: keeps memory flat for libraries with tens of thousands of episodes.
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
IncludeItemTypes = _trackedKinds,
Recursive = true,
IsVirtualItem = false
});
var seen = new HashSet<Guid>(itemIds.Count);
var seriesCache = new Dictionary<Guid, Series?>();
int registered = 0, lowered = 0, ignored = 0, external = 0;
for (var offset = 0; offset < itemIds.Count; offset += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = itemIds.Skip(offset).Take(BatchSize).ToArray();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = batch,
IncludeItemTypes = _trackedKinds,
Recursive = true,
IsVirtualItem = false
});
foreach (var item in items)
{
seen.Add(item.Id);
var identity = _resolver.Resolve(item, seriesId => LookupSeries(seriesId, seriesCache));
if (identity is null || identity.Keys.Count == 0)
{
ignored++;
continue;
}
switch (_reconciler.Register(identity).Action)
{
case ReconcileAction.Registered:
registered++;
break;
case ReconcileAction.FirstAddedLowered:
lowered++;
break;
default:
break;
}
if (_externalSources.Length > 0 && await ApplyExternalSourcesAsync(identity, cancellationToken).ConfigureAwait(false))
{
external++;
}
}
progress.Report(90.0 * Math.Min(offset + BatchSize, itemIds.Count) / itemIds.Count);
}
// Live items the query did not return may be alternate versions or added meanwhile; only drop the ones that are gone.
var stale = _reconciler.GetUnseenLiveItemIds(seen)
.Where(itemId => _libraryManager.GetItemById(itemId) is null)
.ToList();
var detached = _reconciler.RemoveStaleItems(stale);
var pruned = _reconciler.Prune();
_store.MarkSynced(DateTime.UtcNow);
_store.Flush();
progress.Report(100);
var statistics = _store.GetStatistics();
_logger.LogInformation(
"{Kind} abgeschlossen in {Elapsed:g}: {Items} Items geprüft, {Registered} neu registriert, {Lowered} Datum vorverlegt, "
+ "{External} aus externen Quellen, {Ignored} ohne Kennung übersprungen, {Detached} entfernte Items ausgetragen, {Pruned} Einträge bereinigt. "
+ "Ledger: {Entries} Einträge ({Live} mit Items, {Removed} in der Frist)",
isSeed ? "Ledger-Seed" : "Ledger-Synchronisation",
stopwatch.Elapsed,
itemIds.Count,
registered,
lowered,
external,
ignored,
detached,
pruned,
statistics.Entries,
statistics.LiveEntries,
statistics.RemovedEntries);
}
private Series? LookupSeries(Guid seriesId, Dictionary<Guid, Series?> cache)
{
if (!cache.TryGetValue(seriesId, out var series))
{
series = _libraryManager.GetItemById(seriesId) as Series;
cache[seriesId] = series;
}
return series;
}
#pragma warning disable CA1031 // A failing external source must not abort the synchronization.
private async Task<bool> ApplyExternalSourcesAsync(MediaIdentity identity, CancellationToken cancellationToken)
{
var lowered = false;
foreach (var source in _externalSources)
{
try
{
if (await source.GetFirstAddedAsync(identity, cancellationToken).ConfigureAwait(false) is { } firstAdded)
{
lowered |= _reconciler.ApplyExternalFirstAdded(identity.Keys, firstAdded);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Externe Datumsquelle {Source} ist für {Name} fehlgeschlagen", source.Name, identity.DisplayName);
}
}
return lowered;
}
#pragma warning restore CA1031
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using MediaBrowser.Controller.Library;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Writes "date added" through the library manager and remembers which items it is currently saving,
/// so the resulting <c>ItemUpdated</c> events can be ignored.
/// </summary>
public sealed class LibraryItemDateWriter : IItemDateWriter
{
private readonly ILibraryManager _libraryManager;
private readonly ConcurrentDictionary<Guid, byte> _inFlight = new();
/// <summary>
/// Initializes a new instance of the <see cref="LibraryItemDateWriter"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
public LibraryItemDateWriter(ILibraryManager libraryManager)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Checks whether the item is being saved by this writer right now.
/// </summary>
/// <param name="itemId">The item.</param>
/// <returns><c>true</c> while a correction of the item is in progress.</returns>
public bool IsWriting(Guid itemId) => _inFlight.ContainsKey(itemId);
/// <inheritdoc />
public async Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
{
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
return false;
}
_inFlight.TryAdd(itemId, 0);
try
{
item.DateCreated = dateCreated;
// MetadataEdit also lets the NFO saver (if enabled) write the restored <dateadded>.
await _libraryManager.UpdateItemAsync(item, item.GetParent(), ItemUpdateType.MetadataEdit, cancellationToken)
.ConfigureAwait(false);
return true;
}
finally
{
_inFlight.TryRemove(itemId, out _);
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Services;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tasks;
/// <summary>
/// Scheduled task "Ledger synchronisieren": registers unknown items, updates live items and prunes the ledger.
/// It never changes library items.
/// </summary>
public class SyncLedgerTask : IScheduledTask
{
/// <summary>
/// The task key.
/// </summary>
public const string TaskKey = "RecentlyAddedGuardSyncLedger";
private readonly LedgerSynchronizer _synchronizer;
/// <summary>
/// Initializes a new instance of the <see cref="SyncLedgerTask"/> class.
/// </summary>
/// <param name="synchronizer">The synchronizer.</param>
public SyncLedgerTask(LedgerSynchronizer synchronizer)
{
_synchronizer = synchronizer;
}
/// <inheritdoc />
public string Name => "Ledger synchronisieren";
/// <inheritdoc />
public string Key => TaskKey;
/// <inheritdoc />
public string Description
=> "Gleicht das Ledger von Recently Added Guard mit der Bibliothek ab: registriert unbekannte Filme und Episoden, "
+ "trägt gelöschte Items aus und bereinigt abgelaufene Einträge. Ändert keine Items.";
/// <inheritdoc />
public string Category => "Recently Added Guard";
/// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
=> _synchronizer.RunAsync(progress, cancellationToken);
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.DailyTrigger,
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
};
}
}
+147
View File
@@ -0,0 +1,147 @@
# 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.
+21
View File
@@ -0,0 +1,21 @@
---
# Metadata for jprm (Jellyfin Plugin Repository Manager). The release workflow sets
# `version` and `changelog` from the git tag before building; do not change `guid`.
name: "Recently Added Guard"
guid: "6fdfedef-adde-4ac3-a873-62a057c95574"
version: "1.0.0.0"
targetAbi: "12.2.0.0"
framework: "net10.0"
owner: "menzeljonas"
category: "General"
overview: "Hält Qualitäts-Upgrades von Radarr und Sonarr aus „Zuletzt hinzugefügt“ fern."
description: >
Radarr und Sonarr ersetzen Filme und Episoden bei Qualitäts-Upgrades durch neue Dateien.
Jellyfin legt dafür neue Items an, die als „neu hinzugefügt“ erscheinen. Das Plugin führt
ein Ledger mit dem ersten Hinzufügedatum jedes Films und jeder Episode (über TMDb-, IMDb-
und TVDb-IDs sowie die Serie) und setzt das Datum von Upgrades auf diesen Wert zurück.
So erscheinen unter „Zuletzt hinzugefügt“ nur wirklich neue Medien.
artifacts:
- "Jellyfin.Plugin.RecentlyAddedGuard.dll"
changelog: >
Erste Version.
+301
View File
@@ -0,0 +1,301 @@
"""Integration test: Recently Added Guard in a throwaway Jellyfin 12.2 container.
Usage: python3 integration-test/it.py <plugin zip> [--keep]
Starts jellyfin/jellyfin:12.2 on 127.0.0.1:18096 with the plugin installed like the catalog does,
creates a small library (test videos via jellyfin-ffmpeg), simulates Radarr/Sonarr upgrades and checks
the dates and "Recently Added". Needs Docker and internet access (metadata from TMDb).
State lives in integration-test/.run; --keep leaves the container running for inspection.
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
NAME = "rag-it-jellyfin"
IMAGE = "jellyfin/jellyfin:12.2"
PORT = 18096
BASE = f"http://127.0.0.1:{PORT}"
PLUGIN_ID = "6fdfedef-adde-4ac3-a873-62a057c95574"
CLIENT = 'MediaBrowser Client="rag-it", Device="rag-it", DeviceId="rag-it", Version="1.0"'
FFMPEG = "/usr/lib/jellyfin-ffmpeg/ffmpeg"
token = None
failures = []
def log(msg):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def check(cond, msg):
log(("PASS " if cond else "FAIL ") + msg)
if not cond:
failures.append(msg)
def sh(*args, check_rc=True):
return subprocess.run(args, check=check_rc, capture_output=True, text=True)
def api(method, path, body=None, raw=False, expect=(200, 204)):
headers = {"Authorization": CLIENT + (f', Token="{token}"' if token else "")}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = resp.read()
status = resp.status
except urllib.error.HTTPError as e:
payload = e.read()
status = e.code
if status not in expect:
raise RuntimeError(f"{method} {path} -> {status}: {payload[:400]!r}")
if raw:
return status, payload
return json.loads(payload) if payload else None
def make_video(path):
sh("docker", "exec", NAME, "mkdir", "-p", os.path.dirname(path))
sh("docker", "exec", NAME, FFMPEG, "-v", "error", "-f", "lavfi", "-i",
"testsrc=duration=2:size=320x240:rate=10", "-c:v", "libx264", "-y", path)
def rm(path):
sh("docker", "exec", NAME, "rm", "-f", path)
def wait_for(pred, what, timeout=180, interval=2):
end = time.time() + timeout
while time.time() < end:
try:
value = pred()
if value:
return value
except (OSError, RuntimeError, ValueError):
pass # server still starting
time.sleep(interval)
raise RuntimeError(f"timeout waiting for {what}")
def scan_and_settle():
api("POST", "/Library/Refresh")
time.sleep(3)
wait_for(lambda: all(t["State"] == "Idle" for t in api("GET", "/ScheduledTasks?isHidden=false")
if t["Key"] == "RefreshLibrary"), "library scan", timeout=300)
time.sleep(8) # plugin debounce (2 s) + metadata refresh events
def items(kind):
res = api("GET", f"/Items?IncludeItemTypes={kind}&Recursive=true&Fields=DateCreated,Path,ProviderIds")
return res["Items"]
def find(kind, *needles):
return [i for i in items(kind) if all(n in i.get("Path", "") for n in needles)]
def main():
global token
root = os.path.join(HERE, ".run")
sh("docker", "rm", "-f", NAME, check_rc=False)
shutil.rmtree(root, ignore_errors=True)
for d in ("config/plugins", "cache", "media/movies", "media/tv"):
os.makedirs(os.path.join(root, d), exist_ok=True)
# Install like the catalog does: plugins/<Name>_<version>/ with the DLL and meta.json.
zip_path = os.path.abspath(sys.argv[1])
plugin_dir = os.path.join(root, "config/plugins", "Recently Added Guard_1.0.0.0")
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(plugin_dir)
sh("docker", "run", "-d", "--name", NAME, "-p", f"127.0.0.1:{PORT}:8096",
"-v", f"{root}/config:/config", "-v", f"{root}/cache:/cache", "-v", f"{root}/media:/media",
"--memory", "2g", IMAGE)
wait_for(lambda: api("GET", "/System/Info/Public"), "server start")
wait_for(lambda: api("GET", "/Startup/Configuration"), "startup api")
api("POST", "/Startup/Configuration", {"UICulture": "de-DE", "MetadataCountryCode": "DE", "PreferredMetadataLanguage": "de"})
api("GET", "/Startup/User")
api("POST", "/Startup/User", {"Name": "admin", "Password": "admin"})
api("POST", "/Startup/Complete")
auth = api("POST", "/Users/AuthenticateByName", {"Username": "admin", "Pw": "admin"})
token = auth["AccessToken"]
user_id = auth["User"]["Id"]
log("wizard done, authenticated")
plugins = api("GET", "/Plugins")
plugin = next((p for p in plugins if p["Id"].replace("-", "") == PLUGIN_ID.replace("-", "")), None)
check(plugin is not None and plugin["Status"] == "Active", f"plugin loaded and active: {plugin}")
status = wait_for(lambda: (s := api("GET", "/RecentlyAddedGuard/Status")) and s["SeedCompleted"] and s, "seed")
check(status["LedgerEntries"] == 0, f"seed on empty library: {status['LedgerEntries']} entries")
_, page = api("GET", "/web/ConfigurationPage?name=RecentlyAddedGuard", raw=True)
check(b'data-role="page"' in page and PLUGIN_ID.encode() in page, "config page served")
pages = api("GET", "/web/ConfigurationPages")
check(any(p["Name"] == "RecentlyAddedGuard" for p in pages), "config page listed")
# Initial media
matrix_old = "/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) WEBDL-720p.mkv"
andor1_old = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa WEBDL-720p.mkv"
andor2 = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me WEBDL-720p.mkv"
blade_old = "/media/movies/Blade Runner (1982)/Blade Runner (1982) WEBDL-720p.mkv"
for p in (matrix_old, andor1_old, andor2, blade_old):
make_video(p)
api("POST", "/Library/VirtualFolders?name=Filme&collectionType=movies&refreshLibrary=false",
{"LibraryOptions": {"PathInfos": [{"Path": "/media/movies"}], "EnableRealtimeMonitor": False}})
api("POST", "/Library/VirtualFolders?name=Serien&collectionType=tvshows&refreshLibrary=false",
{"LibraryOptions": {"PathInfos": [{"Path": "/media/tv"}], "EnableRealtimeMonitor": False}})
scan_and_settle()
movie = find("Movie", "The Matrix", "720p")[0]
ep1 = find("Episode", "S01E01")[0]
ep2 = find("Episode", "S01E02")[0]
blade = find("Movie", "Blade Runner")[0]
log(f"movie {movie['Id']} provider ids {movie.get('ProviderIds')} date {movie['DateCreated']}")
status = api("GET", "/RecentlyAddedGuard/Status")
check(status["LedgerEntries"] == 4, f"4 media registered by events: {status['LedgerEntries']}")
check(status["CorrectionsSinceStart"] == 0, "no corrections for new media")
# Make the existing items "old" (like an NFO date or an item added long ago): lowers the ledger.
for item, date in ((movie, "2024-01-01T00:00:00.0000000Z"), (ep1, "2024-02-01T00:00:00.0000000Z"),
(ep2, "2024-03-01T00:00:00.0000000Z"),
(blade, "2023-05-01T00:00:00.0000000Z")):
dto = api("GET", f"/Users/{user_id}/Items/{item['Id']}")
dto["DateCreated"] = date
api("POST", f"/Items/{item['Id']}", dto)
time.sleep(6)
check(api("GET", "/RecentlyAddedGuard/Status")["CorrectionsSinceStart"] == 0, "backdating is no upgrade")
# A genuinely new movie
make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv")
# Upgrades with new file names (Radarr/Sonarr style): old file gone, new file there.
rm(matrix_old)
make_video("/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) Bluray-1080p.mkv")
rm(andor1_old)
make_video("/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa Bluray-1080p.mkv")
scan_and_settle()
new_movie = find("Movie", "The Matrix", "Bluray-1080p")[0]
new_ep1 = find("Episode", "Bluray-1080p")[0]
inception = find("Movie", "Inception")[0]
check(new_movie["Id"] != movie["Id"], "upgraded movie is a new item (new path)")
check(new_movie["DateCreated"].startswith("2024-01-01"), f"upgraded movie keeps date: {new_movie['DateCreated']}")
check(new_ep1["DateCreated"].startswith("2024-02-01"), f"upgraded episode keeps date: {new_ep1['DateCreated']}")
check(not inception["DateCreated"].startswith("2024"), f"new movie keeps its own date: {inception['DateCreated']}")
check(not find("Movie", "The Matrix", "720p"), "old movie item removed")
libs = {f["Name"]: f["ItemId"] for f in api("GET", "/Library/VirtualFolders")}
latest_movies = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Filme']}&limit=10")
log("latest movies: " + ", ".join(f"{i['Name']}" for i in latest_movies))
check(latest_movies and latest_movies[0]["Id"] == inception["Id"], "Inception is the newest movie")
latest_tv = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Serien']}&limit=10&groupItems=false")
log("latest episodes: " + ", ".join(f"{i['Name']} {i.get('DateCreated', '')}" for i in latest_tv))
status = api("GET", "/RecentlyAddedGuard/Status")
log(f"status: {json.dumps(status, ensure_ascii=False)}")
check(status["CorrectionsSinceStart"] == 2, f"two corrections recorded: {status['CorrectionsSinceStart']}")
# Same path, new file (new inode -> new creation time); the metadata refresh resets DateCreated.
rm(andor2)
time.sleep(2)
make_video(andor2)
api("POST", f"/Items/{ep2['Id']}/Refresh?metadataRefreshMode=Default&imageRefreshMode=Default&replaceAllMetadata=false")
time.sleep(10)
same = [i for i in items("Episode") if i["Id"] == ep2["Id"]]
check(bool(same) and same[0]["DateCreated"].startswith("2024-03-01"),
f"same-path replacement keeps date: {same[0]['DateCreated'] if same else 'missing'}")
def set_config(**changes):
config = api("GET", f"/Plugins/{PLUGIN_ID}/Configuration")
config.update(changes)
api("POST", f"/Plugins/{PLUGIN_ID}/Configuration", config)
check(all(api("GET", f"/Plugins/{PLUGIN_ID}/Configuration")[k] == v for k, v in changes.items()), f"config saved: {changes}")
# C: movie without id in the folder name; matched once the metadata refresh brings the TMDb id.
rm(blade_old)
make_video("/media/movies/Blade Runner (1982)/Blade Runner (1982) Bluray-2160p.mkv")
scan_and_settle()
time.sleep(10)
blade_new = find("Movie", "Bluray-2160p")[0]
log(f"blade runner new item provider ids {blade_new.get('ProviderIds')}")
check(blade_new["DateCreated"].startswith("2023-05-01"), f"upgrade without id in folder name keeps date: {blade_new['DateCreated']}")
# D: dry run detects but does not change.
set_config(DryRun=True)
andor2_new = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me Bluray-1080p.mkv"
rm(andor2)
make_video(andor2_new)
scan_and_settle()
ep2_new = find("Episode", "S01E02")[0]
status = api("GET", "/RecentlyAddedGuard/Status")
check(not ep2_new["DateCreated"].startswith("2024"), f"dry run leaves the date: {ep2_new['DateCreated']}")
check(status["DryRun"] and status["DryRunDetectionsSinceStart"] >= 1, f"dry run detection counted: {status['DryRunDetectionsSinceStart']}")
set_config(DryRun=False)
# E: a medium returning after the grace period counts as new.
set_config(GraceDays=0)
inception_path = "/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv"
rm(inception_path)
scan_and_settle()
make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) Remux-2160p.mkv")
scan_and_settle()
inception_new = find("Movie", "Remux-2160p")[0]
check(inception_new["DateCreated"] > inception["DateCreated"], f"return after grace counts as new: {inception_new['DateCreated']} > {inception['DateCreated']}")
set_config(GraceDays=14)
# Scheduled task
tasks = api("GET", "/ScheduledTasks?isHidden=false")
task = next((t for t in tasks if t["Key"] == "RecentlyAddedGuardSyncLedger"), None)
check(task is not None and task["Category"] == "Recently Added Guard", "scheduled task registered")
api("POST", "/RecentlyAddedGuard/Sync", expect=(204,))
wait_for(lambda: next(t for t in api("GET", "/ScheduledTasks?isHidden=false")
if t["Key"] == "RecentlyAddedGuardSyncLedger").get("LastExecutionResult"), "sync task run")
result = next(t for t in api("GET", "/ScheduledTasks?isHidden=false") if t["Key"] == "RecentlyAddedGuardSyncLedger")["LastExecutionResult"]
check(result["Status"] == "Completed", f"sync task completed: {result['Status']}")
status = api("GET", "/RecentlyAddedGuard/Status")
log(f"final status: {json.dumps(status, ensure_ascii=False)}")
logs = sh("docker", "logs", NAME, check_rc=False)
lines = [l for l in (logs.stdout + logs.stderr).splitlines() if "RecentlyAddedGuard" in l or "Recently Added Guard" in l
or "Upgrade erkannt" in l or "Ledger" in l or "ERR]" in l]
print("\n".join(lines[-60:]))
ledger = os.path.join(root, "config", "plugins", "Jellyfin.Plugin.RecentlyAddedGuard", "ledger.json")
check(os.path.exists(ledger), f"ledger persisted at {ledger}")
# Restart: ledger survives, no new seed
sh("docker", "restart", NAME)
wait_for(lambda: api("GET", "/System/Info/Public"), "restart")
status = wait_for(lambda: api("GET", "/RecentlyAddedGuard/Status"), "status after restart")
check(status["SeedCompleted"] and status["LedgerEntries"] >= 4, f"ledger reloaded after restart: {status['LedgerEntries']}")
print("\nFAILURES:" if failures else "\nALL CHECKS PASSED")
for f in failures:
print(" - " + f)
return 1 if failures else 0
if __name__ == "__main__":
if len(sys.argv) < 2 or not sys.argv[1].endswith(".zip"):
sys.exit(__doc__)
rc = 1
try:
rc = main()
finally:
if "--keep" not in sys.argv:
subprocess.run(["docker", "rm", "-f", NAME], capture_output=True)
sys.exit(rc)
-20
View File
@@ -1,20 +0,0 @@
[
{
"guid": "6fdfedef-adde-4ac3-a873-62a057c95574",
"name": "Recently Added Guard",
"description": "Radarr und Sonarr ersetzen Filme und Episoden bei Qualit\u00e4ts-Upgrades durch neue Dateien. Jellyfin legt daf\u00fcr neue Items an, die als \u201eneu hinzugef\u00fcgt\u201c erscheinen. Das Plugin f\u00fchrt ein Ledger mit dem ersten Hinzuf\u00fcgedatum jedes Films und jeder Episode (\u00fcber TMDb-, IMDb- und TVDb-IDs sowie die Serie) und setzt das Datum von Upgrades auf diesen Wert zur\u00fcck. So erscheinen unter \u201eZuletzt hinzugef\u00fcgt\u201c nur wirklich neue Medien.\n",
"overview": "H\u00e4lt Qualit\u00e4ts-Upgrades von Radarr und Sonarr aus \u201eZuletzt hinzugef\u00fcgt\u201c fern.",
"owner": "menzeljonas",
"category": "General",
"versions": [
{
"version": "1.0.0.0",
"changelog": "Erste Version\n- Bewahrt das erste Hinzuf\u00fcgedatum pro Film und Episode und setzt es bei Upgrades aus Radarr/Sonarr wieder ein, auch bei neuem Dateinamen.\n- Erkennung \u00fcber TMDb-, IMDb- und TVDb-IDs sowie die Serie.\n- Frist von 14 Tagen, Testmodus (DryRun), Statusseite mit den letzten Korrekturen.\n- Scheduled Task \u201eLedger synchronisieren\u201c.",
"targetAbi": "12.2.0.0",
"sourceUrl": "https://git.menzel.center/menzeljonas/jellyfin-plugin-recently-added-guard/releases/download/v1.0.0.0/recently-added-guard_1.0.0.0.zip",
"checksum": "84315b6ed399f1181d4fba1f6cf2fd0e",
"timestamp": "2026-10-21T21:25:46Z"
}
]
}
]